detached 2bedec2

Merge origin/main into fix/review-hardening

Brings the in-place tree and the repo-wide file finder (PR #11) onto the
ports architecture: ListPaths joins BrowseService, the web path list goes
through it with the request principal, and the finder hooks into ermite.js.
commit
2bedec23f80bfd3b3b48e735ad26157fc491733a
author
brice · · 2026-10-12 06:02 UTC
merge of
582b620 + d5cc8fe · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
88 files · +4407 -559
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 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
@@ -98,6 +98,8 @@ type Routes struct {
9898 ListCommits Backend
9999 // ListCommitsPath is a page of the history of a file or a directory.
100100 ListCommitsPath Backend
101+ // ListPaths is the recursive list of the file paths of a revision.
102+ ListPaths Backend
101103 }
102104
103105 // tree picks the route of a directory listing.
@@ -115,7 +117,7 @@ func (r Routes) tree(path domain.TreePath, lastCommits bool) Backend {
115117 // AllRoutes returns routes that send every method to b. The tests and the
116118 // benchmarks use it to exercise a backend whatever the production routes are.
117119 func AllRoutes(b Backend) Routes {
118- return Routes{b, b, b, b, b, b, b, b, b, b}
120+ return Routes{b, b, b, b, b, b, b, b, b, b, b}
119121 }
120122
121123 // Config configures an Adapter.
@@ -331,6 +333,21 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path
331333 }, cli)
332334 }
333335
336+// ListPaths lists the file and symbolic link paths of a revision.
337+func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
338+ cli := func() (domain.PathList, error) { return a.cli.ListPaths(ctx, repo, rev, maxPaths, maxBytes) }
339+ if a.routes.ListPaths == CLI {
340+ return cli()
341+ }
342+ return serve(ctx, a, repo, func(r *git.Repo) (domain.PathList, error) {
343+ list, err := listPaths(ctx, r, rev, maxPaths, maxBytes)
344+ if err != nil {
345+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
346+ }
347+ return list, nil
348+ }, cli)
349+}
350+
334351 // GetTree lists a directory.
335352 func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
336353 cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) }
M internal/adapters/ermitegit/routes.go +12 -1
@@ -16,7 +16,8 @@ package ermitegit
1616 // a method has several shapes that behave differently, each has its own
1717 // constant: GetTree, GetTreeLastCommits (root), GetTreeSubdirLastCommits,
1818 // ResolveRevisionAbbrev and ListCommitsPath. A method that does not win stays
19-// on the CLI. GetCommit is not routable: the diff stays on git.
19+// on the CLI. GetCommit is not routable: the diff stays on git. ListPaths has
20+// one shape: the fixture lists 131 files and lazygit 2,180.
2021 //
2122 // Warm is a repository that is open; cold closes it before every call, the
2223 // price of the first read after the idle TTL, an eviction or a restart (a
@@ -71,6 +72,10 @@ package ermitegit
7172 // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win
7273 // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win
7374 // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win
75+// ListPaths fixture warm 760µs 118.4K 225 41µs 37.9K 30 win
76+// ListPaths fixture cold 771µs 118.4K 225 107µs 54.7K 160 win
77+// ListPaths lazygit warm 1.8ms 432.9K 2280 842µs 250.3K 477 win
78+// ListPaths lazygit cold 1.8ms 432.9K 2280 1.2ms 298.7K 749 win
7479 // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win
7580 // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba
7681 // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba
@@ -126,6 +131,11 @@ package ermitegit
126131 // - OpenBlob by size: below 1 MiB the small blob loses warm bytes and
127132 // allocations on lazygit; above, it loses time (inflate speed: 52 ms
128133 // against 21 ms). No size makes it win, so there is no size-based route.
134+// - ListPaths (the recursive path list of the finder) wins every line, even
135+// cold, because one walk replaces a process and the parsing of its output
136+// (the bytes of the paths are kept in 32 KiB blocks, which is what brought
137+// the lazygit bytes under the CLI's: a single growing buffer lost them,
138+// 699K against 433K). Routed.
129139 func DefaultRoutes() Routes {
130140 return Routes{
131141 ListRefs: ErmiteGit,
@@ -138,5 +148,6 @@ func DefaultRoutes() Routes {
138148 OpenBlob: CLI,
139149 ListCommits: ErmiteGit,
140150 ListCommitsPath: CLI,
151+ ListPaths: ErmiteGit,
141152 }
142153 }
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
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
2626
2727 // Features are the optional capabilities of the contract that this server
2828 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
3030
3131 // Config configures the API.
3232 type Config struct {
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/rpc/git_test.go +51 -0
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "strings"
67 "testing"
78
89 "connectrpc.com/connect"
@@ -50,6 +51,56 @@ func TestGitServiceGetTree(t *testing.T) {
5051 }
5152 }
5253
54+func mustObjectID(t *testing.T, s string) domain.ObjectID {
55+ t.Helper()
56+ id, err := domain.ParseObjectID(s)
57+ require.NoError(t, err)
58+ return id
59+}
60+
61+func TestGitServiceListPaths(t *testing.T) {
62+ t.Parallel()
63+ commit := mustObjectID(t, strings.Repeat("a", 40))
64+ tree := mustObjectID(t, strings.Repeat("b", 40))
65+ tests := []struct {
66+ name string
67+ req *pb.ListPathsRequest
68+ list domain.PathList
69+ usecase error
70+ wantCode connect.Code
71+ wantCall bool
72+ }{
73+ {
74+ name: "ok", req: &pb.ListPathsRequest{Repo: "demo", Revision: "main"}, wantCall: true,
75+ list: domain.PathList{CommitID: commit, TreeID: tree, Paths: []string{"a.go", "b/c.go"}, Truncated: true},
76+ },
77+ {name: "default revision", req: &pb.ListPathsRequest{Repo: "demo"}, wantCall: true, list: domain.PathList{CommitID: commit, TreeID: tree}},
78+ {name: "invalid repo", req: &pb.ListPathsRequest{Repo: "../x"}, wantCode: connect.CodeInvalidArgument},
79+ {name: "repo not visible", req: &pb.ListPathsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
80+ {name: "revision not found", req: &pb.ListPathsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
81+ }
82+ for _, tt := range tests {
83+ t.Run(tt.name, func(t *testing.T) {
84+ t.Parallel()
85+ browse := mocks.NewBrowseService(t)
86+ if tt.wantCall {
87+ browse.EXPECT().ListPaths(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
88+ Return(tt.list, tt.usecase).Once()
89+ }
90+ resp, err := (&gitService{browse}).ListPaths(ownerCtx(), connect.NewRequest(tt.req))
91+ if tt.wantCode != 0 {
92+ requireCode(t, tt.wantCode, err)
93+ return
94+ }
95+ require.NoError(t, err)
96+ assert.Equal(t, tt.list.CommitID.String(), resp.Msg.GetCommitId())
97+ assert.Equal(t, tt.list.TreeID.String(), resp.Msg.GetTreeId())
98+ assert.Equal(t, tt.list.Paths, resp.Msg.GetPaths())
99+ assert.Equal(t, tt.list.Truncated, resp.Msg.GetTruncated())
100+ })
101+ }
102+}
103+
53104 func TestGitServiceListRefs(t *testing.T) {
54105 t.Parallel()
55106 tests := []struct {
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, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12+| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
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.
@@ -55,6 +55,7 @@ var scripts = []struct {
5555 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
5656 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
5757 {"ermite.js", func(a *Assets, u string) { a.App = u }},
58+ {"files.js", func(a *Assets, u string) { a.Files = u }},
5859 }
5960
6061 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
M internal/adapters/web/blob.go +3 -0
@@ -186,6 +186,9 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
186186 }
187187
188188 l := s.repoLayout(ctx, d, "tree")
189+ // A blob stream does not name its commit: an abbreviated id is resolved
190+ // for the finder.
191+ s.resolveCommit(ctx, d.repo.Name, &t)
189192 pinRef(&l, t)
190193 l.Crumbs = pathCrumbs(d.name(), "blob", t)
191194 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
M internal/adapters/web/browse.go +20 -0
@@ -69,3 +69,23 @@ func (s *Server) openBlobAt(ctx context.Context, repo domain.RepoName, rev, path
6969 }
7070 return s.cfg.Browse.OpenBlob(ctx, principal(ctx), repo, r, p)
7171 }
72+
73+// resolveRevision resolves rev to the commit it designates.
74+//
75+//hexcheck:ignore suspicious-business-logic-in-adapter parses the URL text and calls the use case; no rule is decided here
76+func (s *Server) resolveRevision(ctx context.Context, repo domain.RepoName, rev string) (domain.ResolvedRevision, error) {
77+ r, err := domain.ParseRevision(rev)
78+ if err != nil {
79+ return domain.ResolvedRevision{}, err
80+ }
81+ return s.cfg.Browse.ResolveRevision(ctx, principal(ctx), repo, r)
82+}
83+
84+// listPaths lists the file paths of the commit rev.
85+func (s *Server) listPaths(ctx context.Context, repo domain.RepoName, rev string) (domain.PathList, error) {
86+ r, err := domain.ParseRevision(rev)
87+ if err != nil {
88+ return domain.PathList{}, err
89+ }
90+ return s.cfg.Browse.ListPaths(ctx, principal(ctx), repo, r)
91+}
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
@@ -17,7 +17,7 @@ templ Keys() {
1717 k-esc from:body -> ermite.closeDialogs();
1818 k-help from:body -> ermite.dialog('panel-help');
1919 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
20- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
20+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
2121 k-filter from:body -> ermite.focusFilter();
2222 k-gh from:body -> ermite.go('.brand');
2323 k-s from:body -> ermite.tab('s');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/overlays.templ +4 -4
@@ -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
@@ -96,7 +96,7 @@ templ OverlayCmd(items []CmdItem) {
9696 autocomplete="off"
9797 spellcheck="false"
9898 aria-controls="cmd-list"
99- hx-on:input="ermite.cmdFilter(this)"
99+ hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
100100 hx-on:keydown="ermite.cmdKey(event)"
101101 />
102102 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
M internal/adapters/web/overlays_templ.go +3 -3 generated file: collapsed
A internal/adapters/web/pathcache.go +195 -0
@@ -0,0 +1,195 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "container/list"
7+ "context"
8+ "runtime/debug"
9+ "sync"
10+
11+ "github.com/bnema/zerowrap"
12+)
13+
14+const (
15+ // pathCacheBytes is the budget of the cache of path lists.
16+ pathCacheBytes = 64 << 20
17+ // pathBuilds is how many path lists are built at the same time, for all
18+ // commits and repositories: a build walks a whole tree and holds the
19+ // list in memory.
20+ pathBuilds = 4
21+)
22+
23+// pathCache keeps the gzipped JSON path lists of commits, least recently used
24+// out first, within a budget of bytes. A commit id names its content, so an
25+// entry is right for every repository and every visitor; who may read it is
26+// decided before the cache is asked (Server.paths).
27+//
28+// Concurrent misses for one commit of one repository share a single build,
29+// and only a few builds run at a time.
30+//
31+// A list over a quarter of the budget is not kept (one big list would push
32+// out most of the others): it is built again for each request that needs it.
33+type pathCache struct {
34+ mu sync.Mutex
35+ budget int
36+ size int
37+ lru *list.List // of *pathEntry, most recently used first
38+ byID map[string]*list.Element
39+ building map[string]*pathBuild // by repository and commit
40+ slots chan struct{} // one token per build running
41+}
42+
43+type pathEntry struct {
44+ id string
45+ body []byte
46+}
47+
48+// pathBuild is a build in progress; done is closed when body and err are set.
49+type pathBuild struct {
50+ waiters int // callers that joined it, for the tests; guarded by pathCache.mu
51+ done chan struct{}
52+ body []byte
53+ err error
54+}
55+
56+func newPathCache(budget, builds int) *pathCache {
57+ return &pathCache{
58+ budget: budget, lru: list.New(),
59+ byID: map[string]*list.Element{}, building: map[string]*pathBuild{},
60+ slots: make(chan struct{}, builds),
61+ }
62+}
63+
64+func (c *pathCache) getLocked(id string) ([]byte, bool) {
65+ el, ok := c.byID[id]
66+ if !ok {
67+ return nil, false
68+ }
69+ c.lru.MoveToFront(el)
70+ return el.Value.(*pathEntry).body, true
71+}
72+
73+// putLocked stores a list, evicting the least recently used ones to stay in
74+// budget. A list over a quarter of the budget is not stored.
75+func (c *pathCache) putLocked(id string, body []byte) {
76+ if len(body) > c.budget/4 {
77+ return
78+ }
79+ if el, ok := c.byID[id]; ok {
80+ c.size -= len(el.Value.(*pathEntry).body)
81+ el.Value.(*pathEntry).body = body
82+ c.size += len(body)
83+ c.lru.MoveToFront(el)
84+ } else {
85+ c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body})
86+ c.size += len(body)
87+ }
88+ for c.size > c.budget {
89+ el := c.lru.Back()
90+ e := el.Value.(*pathEntry)
91+ c.lru.Remove(el)
92+ delete(c.byID, e.id)
93+ c.size -= len(e.body)
94+ }
95+}
96+
97+// load returns the list of a commit: from the cache, or from build, which
98+// runs once however many callers of a repository ask at the same time. The
99+// build runs in its own goroutine, so no caller is held by it: each one,
100+// the one that started it included, waits for the build or for its own ctx.
101+//
102+// Only pathBuilds builds run at a time, whatever their commits: a caller that
103+// would start one waits for a free slot, or for its ctx. The build keeps the
104+// slot until it ends, even if every caller has left.
105+//
106+// Builds in progress are keyed by repository and commit, though the cache is
107+// keyed by commit only: a failure of one repository (the commit is missing
108+// there, its access rule changed) must not be handed to the requests of
109+// another one that would have succeeded.
110+func (c *pathCache) load(ctx context.Context, repo, id string, build func() ([]byte, error)) ([]byte, error) {
111+ key := repo + "\x00" + id // a repository name has no NUL
112+ f, body, ok := c.join(key, id)
113+ if ok {
114+ return body, nil
115+ }
116+ if f == nil {
117+ // Nothing to join: take a slot, then look again, since the commit
118+ // may have been built while this caller waited.
119+ select {
120+ case c.slots <- struct{}{}:
121+ case <-ctx.Done():
122+ return nil, ctx.Err()
123+ }
124+ if f, body, ok = c.joinOrStart(ctx, key, id, build); ok {
125+ return body, nil
126+ }
127+ }
128+ select {
129+ case <-f.done:
130+ return f.body, f.err
131+ case <-ctx.Done():
132+ return nil, ctx.Err()
133+ }
134+}
135+
136+// join returns the list of a commit if it is cached, or the build in progress
137+// for it.
138+func (c *pathCache) join(key, id string) (f *pathBuild, body []byte, ok bool) {
139+ c.mu.Lock()
140+ defer c.mu.Unlock()
141+ if b, ok := c.getLocked(id); ok {
142+ return nil, b, true
143+ }
144+ f = c.building[key]
145+ if f != nil {
146+ f.waiters++
147+ }
148+ return f, nil, false
149+}
150+
151+// joinOrStart is join for a caller that holds a slot: it starts the build when
152+// there is still none, and gives the slot back when it does not need it.
153+func (c *pathCache) joinOrStart(ctx context.Context, key, id string, build func() ([]byte, error)) (f *pathBuild, body []byte, ok bool) {
154+ c.mu.Lock()
155+ defer c.mu.Unlock()
156+ if b, ok := c.getLocked(id); ok {
157+ <-c.slots
158+ return nil, b, true
159+ }
160+ if f, ok := c.building[key]; ok {
161+ <-c.slots
162+ f.waiters++
163+ return f, nil, false
164+ }
165+ f = &pathBuild{done: make(chan struct{}), waiters: 1}
166+ c.building[key] = f
167+ go c.run(ctx, key, id, f, build)
168+ return f, nil, false
169+}
170+
171+// run executes a build and publishes it. A panic of build is logged and
172+// handed to the waiters as errBuildPanicked: it cannot propagate, since
173+// nobody above this goroutine could recover it and it would end the process.
174+func (c *pathCache) run(ctx context.Context, key, id string, f *pathBuild, build func() ([]byte, error)) {
175+ defer func() {
176+ if r := recover(); r != nil {
177+ f.body, f.err = nil, errBuildPanicked
178+ zerowrap.Ctx(ctx).Error().Interface("panic", r).Str("stack", string(debug.Stack())).Msg("path list build panicked")
179+ }
180+ c.finish(key, id, f)
181+ }()
182+ f.body, f.err = build()
183+}
184+
185+// finish publishes a build: stored when it worked, then released to the waiters.
186+func (c *pathCache) finish(key, id string, f *pathBuild) {
187+ c.mu.Lock()
188+ delete(c.building, key)
189+ if f.err == nil {
190+ c.putLocked(id, f.body)
191+ }
192+ c.mu.Unlock()
193+ <-c.slots // free before the waiters wake: one of them may start the next build
194+ close(f.done)
195+}
A internal/adapters/web/pathcache_test.go +320 -0
@@ -0,0 +1,320 @@
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+ t.Parallel()
35+ c := newPathCache(100, 1)
36+ blob := func(n int) []byte { return make([]byte, n) }
37+ c.store("a", blob(25))
38+ c.store("b", blob(25))
39+ c.store("e", blob(25))
40+ require.True(t, c.has("a")) // a is now the most recent
41+ c.store("c", blob(25)) // 100 fit
42+ c.store("d", blob(10)) // 110 > 100: b, the least recent, goes
43+ assert.False(t, c.has("b"), "b was evicted")
44+ assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e"))
45+ assert.Equal(t, 85, c.size)
46+
47+ // Replacing an entry accounts for the old size.
48+ c.store("a", blob(10))
49+ assert.Equal(t, 70, c.size)
50+
51+ // A big one pushes out as many as needed.
52+ c.store("f", blob(25))
53+ c.store("g", blob(25))
54+ assert.LessOrEqual(t, c.size, 100)
55+ assert.True(t, c.has("g"))
56+}
57+
58+// A list over a quarter of the budget is not kept, since it would push out the
59+// others; one at the limit is.
60+func TestPathCacheSkipsOversizedEntries(t *testing.T) {
61+ t.Parallel()
62+ c := newPathCache(100, 1)
63+ var builds atomic.Int32
64+ big := func(n int) func() ([]byte, error) {
65+ return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil }
66+ }
67+ for range 2 {
68+ b, err := c.load(context.Background(), "r", "big", big(26))
69+ require.NoError(t, err)
70+ assert.Len(t, b, 26, "the caller still gets the list")
71+ }
72+ assert.EqualValues(t, 2, builds.Load(), "not cached, so built again")
73+ assert.False(t, c.has("big"))
74+ assert.Zero(t, c.size)
75+
76+ for range 2 {
77+ _, err := c.load(context.Background(), "r", "fits", big(25))
78+ require.NoError(t, err)
79+ }
80+ assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept")
81+ assert.Equal(t, 25, c.size)
82+}
83+
84+func TestPathCacheConcurrent(t *testing.T) {
85+ t.Parallel()
86+ c := newPathCache(1000, 1)
87+ var wg sync.WaitGroup
88+ for g := range 8 {
89+ wg.Add(1)
90+ go func() {
91+ defer wg.Done()
92+ for i := range 500 {
93+ c.store(fmt.Sprint((g+i)%17), make([]byte, 10+i%90))
94+ c.has(fmt.Sprint(i % 17))
95+ }
96+ }()
97+ }
98+ wg.Wait()
99+ assert.LessOrEqual(t, c.size, 1000)
100+ sum := 0
101+ for _, el := range c.byID {
102+ sum += len(el.Value.(*pathEntry).body)
103+ }
104+ assert.Equal(t, sum, c.size, "the size is the sum of the entries")
105+ assert.Equal(t, len(c.byID), c.lru.Len())
106+}
107+
108+// waiters is the number of callers waiting for the build of a key, -1 when
109+// none is in flight.
110+func waiters(c *pathCache, repo, id string) int {
111+ c.mu.Lock()
112+ defer c.mu.Unlock()
113+ if f := c.building[repo+"\x00"+id]; f != nil {
114+ return f.waiters
115+ }
116+ return -1
117+}
118+
119+func building(c *pathCache, n int) bool {
120+ c.mu.Lock()
121+ defer c.mu.Unlock()
122+ return len(c.building) == n
123+}
124+
125+// Concurrent misses for one commit build once; a failure is shared and not
126+// stored; a waiter that gives up leaves the build running.
127+func TestPathCacheLoadBuildsOnce(t *testing.T) {
128+ t.Parallel()
129+ c := newPathCache(1000, 4)
130+ var builds atomic.Int32
131+ release := make(chan struct{})
132+ build := func() ([]byte, error) {
133+ builds.Add(1)
134+ <-release
135+ return []byte("list"), nil
136+ }
137+ const callers = 10
138+ var wg sync.WaitGroup
139+ got := make([][]byte, callers)
140+ for i := range got {
141+ wg.Add(1)
142+ go func() {
143+ defer wg.Done()
144+ b, err := c.load(context.Background(), "r", "x", build)
145+ assert.NoError(t, err)
146+ got[i] = b
147+ }()
148+ }
149+ // Every caller waits for the one build before it is released: none can
150+ // find the list cached, so one build serving all ten shows they joined it.
151+ require.Eventually(t, func() bool { return waiters(c, "r", "x") == callers }, 5*time.Second, time.Millisecond)
152+ // A waiter whose context ends does not stop the others.
153+ ctx, cancel := context.WithCancel(context.Background())
154+ cancel()
155+ _, err := c.load(ctx, "r", "x", build)
156+ assert.ErrorIs(t, err, context.Canceled)
157+ close(release)
158+ wg.Wait()
159+ assert.EqualValues(t, 1, builds.Load())
160+ for _, b := range got {
161+ assert.Equal(t, "list", string(b))
162+ }
163+ assert.True(t, c.has("x"))
164+
165+ boom := errors.New("boom")
166+ _, err = c.load(context.Background(), "r", "y", func() ([]byte, error) { return nil, boom })
167+ assert.ErrorIs(t, err, boom)
168+ assert.False(t, c.has("y"), "a failure is not cached")
169+ b, err := c.load(context.Background(), "r", "y", func() ([]byte, error) { return []byte("ok"), nil })
170+ require.NoError(t, err)
171+ assert.Equal(t, "ok", string(b), "the next request builds again")
172+ assert.Empty(t, c.building)
173+}
174+
175+// The builds of all commits share the slots: with one slot, a load of another
176+// commit waits for the running build, and leaves with its ctx if it is
177+// cancelled first. The slot is the build's, not the caller's.
178+func TestPathCacheLoadBoundsBuilds(t *testing.T) {
179+ t.Parallel()
180+ c := newPathCache(1000, 1)
181+ started, release := make(chan struct{}), make(chan struct{})
182+ first := make(chan error, 1)
183+ go func() {
184+ _, err := c.load(context.Background(), "r", "a", func() ([]byte, error) { close(started); <-release; return []byte("a"), nil })
185+ first <- err
186+ }()
187+ <-started
188+
189+ // A second commit waits for the slot...
190+ var secondBuilt atomic.Bool
191+ second := make(chan error, 1)
192+ go func() {
193+ _, err := c.load(context.Background(), "r", "b", func() ([]byte, error) { secondBuilt.Store(true); return []byte("b"), nil })
194+ second <- err
195+ }()
196+ time.Sleep(30 * time.Millisecond)
197+ assert.False(t, secondBuilt.Load(), "no slot, no build")
198+ select {
199+ case <-second:
200+ t.Fatal("the second load did not wait for a slot")
201+ default:
202+ }
203+
204+ // ...or leaves with its ctx.
205+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
206+ defer cancel()
207+ _, err := c.load(ctx, "r", "c", func() ([]byte, error) { t.Error("built without a slot"); return nil, nil })
208+ assert.ErrorIs(t, err, context.DeadlineExceeded)
209+
210+ // A caller of the running build is not held by the slots.
211+ _, err = c.load(ctx, "r", "a", nil)
212+ assert.ErrorIs(t, err, context.DeadlineExceeded, "it joins the build and leaves with its ctx")
213+
214+ close(release)
215+ require.NoError(t, <-first)
216+ require.NoError(t, <-second)
217+ assert.True(t, secondBuilt.Load())
218+ assert.True(t, c.has("a") && c.has("b"))
219+ assert.Empty(t, c.building)
220+ assert.Empty(t, c.slots, "every slot is back")
221+}
222+
223+// The slot of a build is kept after its callers leave, and given back when it
224+// ends, whether it fails or panics.
225+func TestPathCacheSlotOutlivesCallers(t *testing.T) {
226+ t.Parallel()
227+ c := newPathCache(1000, 1)
228+ started, release := make(chan struct{}), make(chan struct{})
229+ ctx, cancel := context.WithCancel(context.Background())
230+ done := make(chan error, 1)
231+ go func() {
232+ _, err := c.load(ctx, "r", "a", func() ([]byte, error) { close(started); <-release; panic("boom") })
233+ done <- err
234+ }()
235+ <-started
236+ cancel()
237+ assert.ErrorIs(t, <-done, context.Canceled)
238+ assert.Len(t, c.slots, 1, "the build still holds its slot")
239+ close(release)
240+ require.Eventually(t, func() bool { return len(c.slots) == 0 && building(c, 0) }, time.Second, time.Millisecond)
241+ b, err := c.load(context.Background(), "r", "b", func() ([]byte, error) { return []byte("ok"), nil })
242+ require.NoError(t, err)
243+ assert.Equal(t, "ok", string(b))
244+}
245+
246+// A build that panics is handed to its waiters as an error, leaves nothing
247+// in flight or cached, and does not take the process down.
248+func TestPathCacheLoadPanic(t *testing.T) {
249+ t.Parallel()
250+ c := newPathCache(1000, 4)
251+ started, release := make(chan struct{}), make(chan struct{})
252+ var once sync.Once
253+ build := func() ([]byte, error) {
254+ once.Do(func() { close(started) })
255+ <-release
256+ panic("boom")
257+ }
258+ errs := make(chan error, 2)
259+ for range 2 {
260+ go func() {
261+ _, err := c.load(context.Background(), "r", "x", build)
262+ errs <- err
263+ }()
264+ }
265+ <-started
266+ require.Eventually(t, func() bool { c.mu.Lock(); defer c.mu.Unlock(); return len(c.building) == 1 }, time.Second, time.Millisecond)
267+ close(release)
268+ for range 2 {
269+ assert.ErrorIs(t, <-errs, errBuildPanicked)
270+ }
271+ assert.Empty(t, c.building)
272+ assert.False(t, c.has("x"), "a panic is not cached")
273+
274+ b, err := c.load(context.Background(), "r", "x", func() ([]byte, error) { return []byte("ok"), nil })
275+ require.NoError(t, err)
276+ assert.Equal(t, "ok", string(b), "the next request builds again")
277+}
278+
279+// The caller that starts a build is released with its context too, and the
280+// build goes on for the others.
281+func TestPathCacheLoadStarterLeaves(t *testing.T) {
282+ t.Parallel()
283+ c := newPathCache(1000, 4)
284+ release := make(chan struct{})
285+ build := func() ([]byte, error) { <-release; return []byte("list"), nil }
286+ ctx, cancel := context.WithCancel(context.Background())
287+ done := make(chan error, 1)
288+ go func() { _, err := c.load(ctx, "r", "x", build); done <- err }()
289+ require.Eventually(t, func() bool { c.mu.Lock(); defer c.mu.Unlock(); return len(c.building) == 1 }, time.Second, time.Millisecond)
290+ cancel()
291+ select {
292+ case err := <-done:
293+ assert.ErrorIs(t, err, context.Canceled)
294+ case <-time.After(time.Second):
295+ t.Fatal("the starter is held by its build")
296+ }
297+ close(release)
298+ require.Eventually(t, func() bool { return c.has("x") }, time.Second, time.Millisecond)
299+ assert.Empty(t, c.building)
300+}
301+
302+// A failure belongs to the repository that built it: another repository
303+// asking for the same commit builds its own.
304+func TestPathCacheLoadFailureStaysInRepo(t *testing.T) {
305+ t.Parallel()
306+ c := newPathCache(1000, 4)
307+ started, release := make(chan struct{}), make(chan struct{})
308+ boom := errors.New("boom")
309+ failing := make(chan error, 1)
310+ go func() {
311+ _, err := c.load(context.Background(), "a", "x", func() ([]byte, error) { close(started); <-release; return nil, boom })
312+ failing <- err
313+ }()
314+ <-started
315+ b, err := c.load(context.Background(), "b", "x", func() ([]byte, error) { return []byte("list"), nil })
316+ require.NoError(t, err)
317+ assert.Equal(t, "list", string(b))
318+ close(release)
319+ assert.ErrorIs(t, <-failing, boom)
320+}
A internal/adapters/web/paths.go +162 -0
@@ -0,0 +1,162 @@
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+
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// errBuildPanicked is what the waiters of a path list get when its build
19+// panicked.
20+var errBuildPanicked = errors.New("web: building the path list failed")
21+
22+// pathList is the JSON body of GET /{repo}/paths/{sha}.
23+type pathList struct {
24+ Paths []string `json:"paths"`
25+ Truncated bool `json:"truncated"`
26+}
27+
28+// paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for
29+// the file finder (files.js). The commit must be a full id; the answer never
30+// changes for it, but who may read it does, so every request is authorized
31+// first (ResolveRevision runs the access rule, as the caller of the request) and only then served from the
32+// cache of lists. Caching follows raw: a visitor revalidates every time, with
33+// an ETag that makes it a 304 (answered before the list is loaded, so it never
34+// waits for a build); the owner's copy is private and never stored. The ETag
35+// differs for the gzip and the plain variant.
36+//
37+//hexcheck:ignore suspicious-business-logic-in-adapter serves a cached list after the use case authorized it; the headers are about the response, no rule is decided here
38+func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
39+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
40+ defer cancel()
41+ sha := r.PathValue("sha")
42+ if !isFullID(sha) {
43+ s.fail(ctx, w, r, errNotFound)
44+ return
45+ }
46+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
47+ if err != nil {
48+ s.fail(ctx, w, r, err)
49+ return
50+ }
51+ res, err := s.resolveRevision(ctx, repo, sha)
52+ if err != nil {
53+ s.fail(ctx, w, r, err)
54+ return
55+ }
56+ // A ref can be named like a commit id: only the commit itself is served,
57+ // since the cache is keyed by it.
58+ if res.CommitID.String() != sha {
59+ s.fail(ctx, w, r, errNotFound)
60+ return
61+ }
62+
63+ // The validator names the variant that would be served: the gzip bytes
64+ // and the plain JSON are different representations.
65+ gz := acceptsGzip(r.Header)
66+ etag := `"paths-` + sha + `"`
67+ if gz {
68+ etag = `"paths-` + sha + `-gz"`
69+ }
70+ // Revalidated every time, as raw is: a repository can turn private, and a
71+ // shared cache must not keep serving its files. The ETag keeps it cheap.
72+ validators := func(h http.Header) {
73+ h.Set("Vary", "Accept-Encoding")
74+ h.Set("ETag", etag)
75+ cacheHeaders(ctx, h, "no-cache", sessionCache)
76+ }
77+ // Access is settled: a revalidation is answered without the list, so it
78+ // never waits for a build.
79+ if etagMatches(r.Header.Get("If-None-Match"), etag) {
80+ validators(w.Header())
81+ w.WriteHeader(http.StatusNotModified)
82+ return
83+ }
84+ body, err := s.pathCache.load(ctx, repo.String(), sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) })
85+ if err != nil {
86+ s.fail(ctx, w, r, err)
87+ return
88+ }
89+ // Authorized, not modified and loaded: all that is left is to send it.
90+ validators(w.Header())
91+ if err := writePathList(w, r, body, gz); err != nil {
92+ s.fail(ctx, w, r, err)
93+ }
94+}
95+
96+// writePathList sends a list (gzipped JSON, as the cache keeps it): as it is
97+// to a client that accepts gzip, unzipped to the others. The validators are
98+// already set. It fails only before anything is written.
99+func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) error {
100+ var plain *gzip.Reader
101+ if !gz {
102+ var err error
103+ if plain, err = gzip.NewReader(bytes.NewReader(body)); err != nil {
104+ return err
105+ }
106+ }
107+ h := w.Header()
108+ h.Set("Content-Type", "application/json")
109+ h.Set("X-Content-Type-Options", "nosniff")
110+ if gz {
111+ h.Set("Content-Encoding", "gzip")
112+ h.Set("Content-Length", strconv.Itoa(len(body)))
113+ w.WriteHeader(http.StatusOK)
114+ if r.Method != http.MethodHead {
115+ _, _ = w.Write(body)
116+ }
117+ return nil
118+ }
119+ w.WriteHeader(http.StatusOK)
120+ if r.Method != http.MethodHead {
121+ _, _ = io.Copy(w, plain)
122+ }
123+ return nil
124+}
125+
126+// buildPaths lists the paths of a commit and returns them as gzipped JSON.
127+// The build is shared by the requests that wait for it, so it does not stop
128+// when the request that started it ends.
129+func (s *Server) buildPaths(ctx context.Context, repo domain.RepoName, sha string) ([]byte, error) {
130+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout)
131+ defer cancel()
132+ list, err := s.listPaths(ctx, repo, sha)
133+ if err != nil {
134+ return nil, err
135+ }
136+ if list.CommitID.String() != sha {
137+ return nil, errNotFound
138+ }
139+ paths := list.Paths
140+ if paths == nil {
141+ paths = []string{}
142+ }
143+ var buf bytes.Buffer
144+ zw, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
145+ if err != nil {
146+ return nil, err
147+ }
148+ enc := json.NewEncoder(zw)
149+ enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are
150+ if err := enc.Encode(pathList{Paths: paths, Truncated: list.Truncated}); err != nil {
151+ return nil, err
152+ }
153+ if err := zw.Close(); err != nil {
154+ return nil, err
155+ }
156+ // Not buf.Bytes(): the buffer's spare capacity would stay in the cache
157+ // without being counted in its budget. (bytes.Clone may round the
158+ // capacity up, so the copy is exact.)
159+ body := make([]byte, buf.Len())
160+ copy(body, buf.Bytes())
161+ return body, nil
162+}
A internal/adapters/web/paths_test.go +216 -0
@@ -0,0 +1,216 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "errors"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "strings"
14+ "testing"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+)
24+
25+// pathsSHA is the commit the path list tests ask for.
26+var pathsSHA = strings.Repeat("a", 40)
27+
28+// pathsBrowse resolves any full id to itself and lists a.go and b/c.go (or
29+// paths) for the commit, as the anonymous visitor. lists is how many times
30+// the list may be built: 0 means ListPaths must not be called.
31+func pathsBrowse(t testing.TB, lists int, paths []string) *mocks.BrowseService {
32+ t.Helper()
33+ browse := mocks.NewBrowseService(t)
34+ browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn(
35+ func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
36+ return domain.ResolvedRevision{CommitID: objectID(t, rev.String())}, nil
37+ }).Maybe()
38+ if lists > 0 {
39+ if paths == nil {
40+ paths = []string{"a.go", "b/c.go"}
41+ }
42+ browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(
43+ domain.PathList{CommitID: objectID(t, pathsSHA), Paths: paths}, nil).Times(lists)
44+ }
45+ return browse
46+}
47+
48+func getPaths(srv *Server, sha string, hdr map[string]string) *httptest.ResponseRecorder {
49+ req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil)
50+ for k, v := range hdr {
51+ req.Header.Set(k, v)
52+ }
53+ rec := httptest.NewRecorder()
54+ srv.ServeHTTP(rec, req)
55+ return rec
56+}
57+
58+// A revalidation is answered after the access check and before the list is
59+// loaded: it never builds (the mock has no ListPaths to answer). Each variant
60+// has its own validator.
61+func TestPathsRevalidationDoesNotBuild(t *testing.T) {
62+ t.Parallel()
63+ gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"`
64+ tests := []struct{ name, ae, inm string }{
65+ {"gzip", "gzip", gzTag},
66+ {"identity", "", plainTag},
67+ {"weak", "gzip", "W/" + gzTag},
68+ {"any", "", "*"},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.name, func(t *testing.T) {
72+ t.Parallel()
73+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: pathsBrowse(t, 0, nil)})
74+ rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": tt.ae, "If-None-Match": tt.inm})
75+ assert.Equal(t, http.StatusNotModified, rec.Code)
76+ assert.Empty(t, rec.Body.String())
77+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
78+ assert.Equal(t, "Accept-Encoding", rec.Header().Get("Vary"))
79+ assert.NotEmpty(t, rec.Header().Get("ETag"))
80+ })
81+ }
82+}
83+
84+// The list is built once, then served from the cache, in the variant the
85+// client accepts; the validator of the other variant does not match.
86+func TestPathsServesAndCaches(t *testing.T) {
87+ t.Parallel()
88+ gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"`
89+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: pathsBrowse(t, 1, nil)})
90+
91+ assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code)
92+ assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"If-None-Match": gzTag}).Code)
93+
94+ rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip"})
95+ assert.Equal(t, gzTag, rec.Header().Get("ETag"))
96+ assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
97+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
98+ zr, err := gzip.NewReader(rec.Body)
99+ require.NoError(t, err)
100+ out, err := io.ReadAll(zr)
101+ require.NoError(t, err)
102+ assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out))
103+
104+ rec = getPaths(srv, pathsSHA, nil)
105+ assert.Equal(t, plainTag, rec.Header().Get("ETag"))
106+ assert.Empty(t, rec.Header().Get("Content-Encoding"))
107+ assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String())
108+}
109+
110+// The commit is checked as the caller of the request, and only a full id that
111+// resolves to itself is served.
112+func TestPathsRefusals(t *testing.T) {
113+ t.Parallel()
114+ other := strings.Repeat("b", 40)
115+ tests := []struct {
116+ name string
117+ sha string
118+ resolve func() (domain.ResolvedRevision, error)
119+ want int
120+ }{
121+ {"not a full id", "main", nil, http.StatusNotFound},
122+ {"abbreviated id", "aaaaaaa", nil, http.StatusNotFound},
123+ {"commit not found or refused to the caller", pathsSHA, func() (domain.ResolvedRevision, error) { return domain.ResolvedRevision{}, domain.ErrNotFound }, http.StatusNotFound},
124+ {"a ref named like a commit", pathsSHA, func() (domain.ResolvedRevision, error) {
125+ return domain.ResolvedRevision{CommitID: objectID(t, other)}, nil
126+ }, http.StatusNotFound},
127+ {"backend failure", pathsSHA, func() (domain.ResolvedRevision, error) {
128+ return domain.ResolvedRevision{}, errors.New("boom")
129+ }, http.StatusInternalServerError},
130+ }
131+ for _, tt := range tests {
132+ t.Run(tt.name, func(t *testing.T) {
133+ t.Parallel()
134+ browse := mocks.NewBrowseService(t)
135+ if tt.resolve != nil {
136+ browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn(
137+ func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.ResolvedRevision, error) {
138+ return tt.resolve()
139+ })
140+ }
141+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
142+ assert.Equal(t, tt.want, getPaths(srv, tt.sha, nil).Code)
143+ })
144+ }
145+}
146+
147+// The list is built as the caller of the request: a failure of the use case
148+// is the answer.
149+func TestPathsListFailure(t *testing.T) {
150+ t.Parallel()
151+ tests := []struct {
152+ name string
153+ err error
154+ want int
155+ }{
156+ {"not found", domain.ErrNotFound, http.StatusNotFound},
157+ {"other", errors.New("boom"), http.StatusInternalServerError},
158+ }
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ t.Parallel()
162+ browse := pathsBrowse(t, 0, nil)
163+ browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(domain.PathList{}, tt.err)
164+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
165+ assert.Equal(t, tt.want, getPaths(srv, pathsSHA, nil).Code)
166+ })
167+ }
168+}
169+
170+// The cached list holds no spare capacity; the truncation is reported; a path
171+// is not HTML, so "<", ">" and "&" are not escaped in the JSON.
172+func TestBuildPaths(t *testing.T) {
173+ t.Parallel()
174+ tests := []struct {
175+ name string
176+ paths []string
177+ truncated bool
178+ want string
179+ }{
180+ {"plain", []string{"a.go", "b/c.go"}, false, `{"paths":["a.go","b/c.go"],"truncated":false}`},
181+ {"empty", nil, false, `{"paths":[],"truncated":false}`},
182+ {"truncated", []string{"a.go"}, true, `{"paths":["a.go"],"truncated":true}`},
183+ {"html characters", []string{"a&b/<c>.go"}, false, `{"paths":["a&b/<c>.go"],"truncated":false}`},
184+ }
185+ for _, tt := range tests {
186+ t.Run(tt.name, func(t *testing.T) {
187+ t.Parallel()
188+ browse := mocks.NewBrowseService(t)
189+ browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(
190+ domain.PathList{CommitID: objectID(t, pathsSHA), Paths: tt.paths, Truncated: tt.truncated}, nil)
191+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
192+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Anonymous())
193+ body, err := srv.buildPaths(ctx, repoName(t, "r"), pathsSHA)
194+ require.NoError(t, err)
195+ assert.Equal(t, len(body), cap(body))
196+ zr, err := gzip.NewReader(bytes.NewReader(body))
197+ require.NoError(t, err)
198+ out, err := io.ReadAll(zr)
199+ require.NoError(t, err)
200+ assert.Equal(t, tt.want, strings.TrimSpace(string(out)))
201+ })
202+ }
203+}
204+
205+// A list that answers for another commit than the one asked is refused.
206+func TestBuildPathsWrongCommit(t *testing.T) {
207+ t.Parallel()
208+ browse := mocks.NewBrowseService(t)
209+ browse.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(
210+ domain.PathList{CommitID: objectID(t, strings.Repeat("b", 40))}, nil)
211+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
212+ _, err := srv.buildPaths(context.Background(), repoName(t, "r"), pathsSHA)
213+ assert.ErrorIs(t, err, domain.ErrNotFound)
214+}
215+
216+var _ ports.BrowseService = (*mocks.BrowseService)(nil)
M internal/adapters/web/raw.go +1 -1
@@ -77,7 +77,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
7777 // id of the blob at a path: a tree listing of its directory costs more
7878 // than opening a small file.
7979 cacheHeaders(ctx, h, "no-cache", sessionCache)
80- if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") {
80+ if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) {
8181 w.WriteHeader(http.StatusNotModified)
8282 return
8383 }
M internal/adapters/web/raw_test.go +4 -1
@@ -61,7 +61,10 @@ func TestRawRevalidation(t *testing.T) {
6161 {"no validator", "/r/raw/main/docs/a.txt", http.MethodGet, "", http.StatusOK, "hello"},
6262 {"matching validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotModified, ""},
6363 {"other validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"ffffffffffffffffffffffffffffffffffffffff"`, http.StatusOK, "hello"},
64- {"weak validator is not a match", "/r/raw/main/docs/a.txt", http.MethodGet, `W/"` + blobID + `"`, http.StatusOK, "hello"},
64+ {"weak validator matches (weak comparison)", "/r/raw/main/docs/a.txt", http.MethodGet, `W/"` + blobID + `"`, http.StatusNotModified, ""},
65+ {"list of validators", "/r/raw/main/docs/a.txt", http.MethodGet, `"other", "` + blobID + `"`, http.StatusNotModified, ""},
66+ {"any validator", "/r/raw/main/docs/a.txt", http.MethodGet, "*", http.StatusNotModified, ""},
67+ {"list without a match", "/r/raw/main/docs/a.txt", http.MethodGet, `"other", "another"`, http.StatusOK, "hello"},
6568 {"head", "/r/raw/main/docs/a.txt", http.MethodHead, "", http.StatusOK, ""},
6669 {"missing file", "/r/raw/main/docs/none.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},
6770 {"directory", "/r/raw/main", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},
M internal/adapters/web/refs.go +45 -1
@@ -3,6 +3,7 @@
33 package web
44
55 import (
6+ "context"
67 "net/url"
78 "strings"
89
@@ -79,6 +80,34 @@ type target struct {
7980 path string
8081 // detached is true when the URL names a commit and not a ref.
8182 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+//
101+//hexcheck:ignore suspicious-business-logic-in-adapter learns the full id of the commit a URL names for the finder links; no rule is decided here
102+func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {
103+ if !t.detached || isFullID(t.rev) {
104+ return
105+ }
106+ res, err := s.resolveRevision(ctx, repo, t.rev)
107+ if err != nil {
108+ return
109+ }
110+ t.commit = res.CommitID.String()
82111 }
83112
84113 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
@@ -134,13 +163,14 @@ func (d *repoData) isBranchHead(t target) bool {
134163
135164 // pinRef points the ref switcher of the layout at what a page shows: a
136165 // branch or a tag, which is then the current one of the lists, or a commit,
137-// which makes none current.
166+// which makes none current. It points the file finder at the commit too.
138167 func pinRef(l *Layout, t target) {
139168 if t.detached {
140169 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
141170 if len(t.rev) == 40 {
142171 l.SHA = t.rev
143172 }
173+ l.setFinder(t.commitID(), t.rev)
144174 for i := range l.Heads {
145175 l.Heads[i].Current = false
146176 }
@@ -150,6 +180,7 @@ func pinRef(l *Layout, t target) {
150180 return
151181 }
152182 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
183+ l.setFinder(t.rev, t.name)
153184 for i := range l.Heads {
154185 l.Heads[i].Current = l.Heads[i].Name == t.name
155186 }
@@ -158,6 +189,19 @@ func pinRef(l *Layout, t target) {
158189 }
159190 }
160191
192+// setFinder points the file finder at a commit: the list of its paths when sha
193+// is a full commit id, and the blob pages of ref (a ref name or a commit id)
194+// its results open. Without a full id there is nothing to search and the
195+// page has no finder data.
196+func (l *Layout) setFinder(sha, ref string) {
197+ l.PathsURL, l.BlobBase = "", ""
198+ if l.Repo == "" || ref == "" || !isFullID(sha) {
199+ return
200+ }
201+ l.PathsURL = repoHref(l.Repo, "paths", sha)
202+ l.BlobBase = repoHref(l.Repo, "blob", ref)
203+}
204+
161205 func hasName(items []RefItem, name string) bool {
162206 for _, it := range items {
163207 if it.Name == name {
M internal/adapters/web/repo.go +1 -0
@@ -121,6 +121,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
121121 def = d.head.ShortName
122122 l.Ref, l.RefPath = def, def
123123 l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String())
124+ l.setFinder(l.SHA, def)
124125 }
125126 mk := func(key, label, href, id string) Section {
126127 return Section{Key: key, Label: label, Href: href, Current: section == id}
M internal/adapters/web/shell.templ +11 -1
@@ -21,8 +21,18 @@ templ Shell(l Layout, rail templ.Component) {
2121 <script src={ l.Assets.Live } defer></script>
2222 <script src={ l.Assets.SSE } defer></script>
2323 <script src={ l.Assets.App } defer></script>
24+ <script src={ l.Assets.Files } defer></script>
2425 </head>
25- <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel="" data-nav="">
26+ <body
27+ data-ref={ l.RefPath }
28+ data-sha={ l.SHA }
29+ data-sel=""
30+ data-nav=""
31+ if l.PathsURL != "" {
32+ data-paths={ l.PathsURL }
33+ data-blob={ l.BlobBase }
34+ }
35+ >
2636 <a class="skip" href="#main">skip to content</a>
2737 @Header(l)
2838 <div class={ "work", templ.KV("has-rail", l.HasRail && rail != nil) }>
M internal/adapters/web/shell_templ.go +179 -130 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);
M internal/adapters/web/static/js/ermite.js +9 -3
@@ -110,7 +110,11 @@
110110 // moving the cursor, and returns the rows.
111111 hide(input) {
112112 const t = input.value.toLowerCase();
113- const rows = $$(input.dataset.filter || input.dataset.pick);
113+ // A bare data-filter marks a field with its own search (the file finder
114+ // of the Files view): there are no rows to hide.
115+ const sel = input.dataset.filter || input.dataset.pick;
116+ if (!sel) return [];
117+ const rows = $$(sel);
114118 for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
115119 if (input.dataset.groups) {
116120 for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
@@ -120,10 +124,12 @@
120124 // The "go to" palette.
121125 cmdFilter(input) {
122126 const t = input.value.toLowerCase().trim();
123- const items = $$('#cmd-list > li');
127+ // The file hits the finder adds (li.finder-hit, files.js) have no data-k:
128+ // the finder owns them.
129+ const items = $$('#cmd-list > li[data-k]');
124130 for (const li of items) li.hidden = !li.dataset.k.includes(t);
125131 const first = items.find((li) => !li.hidden);
126- if (first) setCursor(first, items);
132+ if (first) setCursor(first, $$('#cmd-list > li'));
127133 },
128134 cmdKey(e) {
129135 const vis = $$('#cmd-list > li:not([hidden])');
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
@@ -11,14 +11,14 @@ import (
1111 "bnema.dev/ermite/internal/domain"
1212 )
1313
14-// maxFileTargets bounds the files of a directory offered in the ":" list.
15-const maxFileTargets = 200
16-
1714 // TreeRow is one entry of a directory.
1815 type TreeRow struct {
19- Glyph string // ▾ directory, @ symbolic link, + submodule, · file
20- Name string
21- Href string // "" for a submodule
16+ Glyph string // ▸ directory, @ symbolic link, + submodule, · file
17+ Name string
18+ Href string // "" for a submodule
19+ // Expand is the URL of the rows of a directory (Href?rows); "" for any
20+ // other entry.
21+ Expand string
2222 Size string
2323 Subject string
2424 CHref string
@@ -34,6 +34,9 @@ type TreePage struct {
3434 Readme *Readme
3535 // Notes say what the listing leaves out.
3636 Notes []string
37+ // More, for the rows of a directory expanded in place, is the page of the
38+ // directory when it has more entries than are listed; "" otherwise.
39+ More string
3740 }
3841
3942 // tree lists a directory of a ref, with the last commit of each entry, and
@@ -56,14 +59,15 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
5659 entries := res.Entries
5760 now := s.cfg.Now()
5861
62+ rowsOnly := r.URL.Query().Has("rows")
5963 p := TreePage{}
60- l := s.repoLayout(ctx, d, "tree")
6164 for i, e := range entries {
62- row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0}
65+ row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly}
6366 switch e.Kind {
6467 case domain.EntryDirectory:
65- row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"
68+ row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-"
6669 row.Href = repoHref(d.name(), "tree", t.name, e.Path)
70+ row.Expand = row.Href + "?rows"
6771 case domain.EntrySymlink:
6872 row.Glyph = "@"
6973 row.Href = repoHref(d.name(), "blob", t.name, e.Path)
@@ -71,9 +75,6 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
7175 row.Glyph, row.Size = "+", "-"
7276 default:
7377 row.Href = repoHref(d.name(), "blob", t.name, e.Path)
74- if len(l.Cmd) < maxFileTargets {
75- l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.Path), Href: row.Href})
76- }
7778 }
7879 if c := e.LastCommit; c != nil {
7980 last := commitRow(d.name(), *c, now)
@@ -81,6 +82,13 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
8182 }
8283 p.Rows = append(p.Rows, row)
8384 }
85+ if rowsOnly {
86+ // The rows alone, for a directory opened in place (files.js).
87+ if res.Truncated {
88+ p.More = repoHref(d.name(), "tree", t.name, t.path)
89+ }
90+ return TreeRows(p), http.StatusOK, nil
91+ }
8492 if res.Truncated {
8593 p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")
8694 }
@@ -95,6 +103,10 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
95103 p.Readme = rd.readme
96104 }
97105
106+ l := s.repoLayout(ctx, d, "tree")
107+ // The finder searches the commit this page lists, whichever way the URL
108+ // spelled it (an abbreviated id included): the listing knows it.
109+ t.commit = res.CommitID.String()
98110 pinRef(&l, t)
99111 l.Crumbs = pathCrumbs(d.name(), "tree", t)
100112 if t.path == "" {
M internal/adapters/web/tree.templ +58 -28
@@ -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"
14- data-filter="#tree-rows > tr"
15- placeholder="Filter this directory…"
14+ data-filter
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="ermite.filter(this)"
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
@@ -88,7 +88,9 @@ type Server struct {
8888 origin string // scheme://host of the public URL, what a POST must come from
8989 assets *assetSet
9090 md *markdown
91- mux *http.ServeMux
91+ // pathCache holds the path lists the file finder fetches.
92+ pathCache *pathCache
93+ mux *http.ServeMux
9294 // refsFan shares the fragments of the refs page between the watchers of
9395 // a push; entries are few and short-lived.
9496 refsFan fanout
@@ -115,7 +117,8 @@ func New(cfg Config) (*Server, error) {
115117 }
116118 s := &Server{
117119 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
118- origin: originOf(u),
120+ pathCache: newPathCache(pathCacheBytes, pathBuilds),
121+ origin: originOf(u),
119122 }
120123 s.mux = s.routes()
121124 return s, nil
@@ -143,6 +146,7 @@ func (s *Server) routes() *http.ServeMux {
143146 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
144147 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
145148 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
149+ mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
146150 if s.cfg.MergeRequests != nil {
147151 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
148152 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
M internal/app/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 internal/app/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 internal/app/testdata/web/blob-binary.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -174,7 +176,7 @@
174176 <dt>
175177 <kbd>/</kbd>
176178 </dt>
177-<dd>filter the list</dd>
179+<dd>filter the list; on Files, find a file in the repository</dd>
178180 </div>
179181 <div>
180182 <dt>
@@ -261,10 +263,10 @@
261263 </section>
262264 </div>
263265 </dialog>
264-<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()">
266+<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()">
265267 <div class="panel-head">
266268 <label for="cmd-q" class="sr">go to</label>
267-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
269+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
268270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
269271 <kbd>esc</kbd>
270272 </button>
@@ -343,7 +345,7 @@
343345 k-esc from:body -> ermite.closeDialogs();
344346 k-help from:body -> ermite.dialog('panel-help');
345347 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
346- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
348+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
347349 k-filter from:body -> ermite.focusFilter();
348350 k-gh from:body -> ermite.go('.brand');
349351 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-code.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <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,10 +333,10 @@
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>
337-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
339+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
338340 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
339341 <kbd>esc</kbd>
340342 </button>
@@ -413,7 +415,7 @@
413415 k-esc from:body -> ermite.closeDialogs();
414416 k-help from:body -> ermite.dialog('panel-help');
415417 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
416- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
418+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
417419 k-filter from:body -> ermite.focusFilter();
418420 k-gh from:body -> ermite.go('.brand');
419421 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-empty.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -174,7 +176,7 @@
174176 <dt>
175177 <kbd>/</kbd>
176178 </dt>
177-<dd>filter the list</dd>
179+<dd>filter the list; on Files, find a file in the repository</dd>
178180 </div>
179181 <div>
180182 <dt>
@@ -261,10 +263,10 @@
261263 </section>
262264 </div>
263265 </dialog>
264-<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()">
266+<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()">
265267 <div class="panel-head">
266268 <label for="cmd-q" class="sr">go to</label>
267-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
269+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
268270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
269271 <kbd>esc</kbd>
270272 </button>
@@ -343,7 +345,7 @@
343345 k-esc from:body -> ermite.closeDialogs();
344346 k-help from:body -> ermite.dialog('panel-help');
345347 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
346- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
348+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
347349 k-filter from:body -> ermite.focusFilter();
348350 k-gh from:body -> ermite.go('.brand');
349351 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-image.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -176,7 +178,7 @@
176178 <dt>
177179 <kbd>/</kbd>
178180 </dt>
179-<dd>filter the list</dd>
181+<dd>filter the list; on Files, find a file in the repository</dd>
180182 </div>
181183 <div>
182184 <dt>
@@ -263,10 +265,10 @@
263265 </section>
264266 </div>
265267 </dialog>
266-<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()">
268+<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()">
267269 <div class="panel-head">
268270 <label for="cmd-q" class="sr">go to</label>
269-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
271+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
270272 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
271273 <kbd>esc</kbd>
272274 </button>
@@ -345,7 +347,7 @@
345347 k-esc from:body -> ermite.closeDialogs();
346348 k-help from:body -> ermite.dialog('panel-help');
347349 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
348- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
350+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
349351 k-filter from:body -> ermite.focusFilter();
350352 k-gh from:body -> ermite.go('.brand');
351353 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-markdown.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -241,7 +243,7 @@
241243 <dt>
242244 <kbd>/</kbd>
243245 </dt>
244-<dd>filter the list</dd>
246+<dd>filter the list; on Files, find a file in the repository</dd>
245247 </div>
246248 <div>
247249 <dt>
@@ -328,10 +330,10 @@
328330 </section>
329331 </div>
330332 </dialog>
331-<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()">
333+<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()">
332334 <div class="panel-head">
333335 <label for="cmd-q" class="sr">go to</label>
334-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
336+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
335337 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
336338 <kbd>esc</kbd>
337339 </button>
@@ -410,7 +412,7 @@
410412 k-esc from:body -> ermite.closeDialogs();
411413 k-help from:body -> ermite.dialog('panel-help');
412414 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
413- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
415+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
414416 k-filter from:body -> ermite.focusFilter();
415417 k-gh from:body -> ermite.go('.brand');
416418 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-svg.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -199,7 +201,7 @@
199201 <dt>
200202 <kbd>/</kbd>
201203 </dt>
202-<dd>filter the list</dd>
204+<dd>filter the list; on Files, find a file in the repository</dd>
203205 </div>
204206 <div>
205207 <dt>
@@ -286,10 +288,10 @@
286288 </section>
287289 </div>
288290 </dialog>
289-<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()">
291+<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()">
290292 <div class="panel-head">
291293 <label for="cmd-q" class="sr">go to</label>
292-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
294+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
293295 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
294296 <kbd>esc</kbd>
295297 </button>
@@ -368,7 +370,7 @@
368370 k-esc from:body -> ermite.closeDialogs();
369371 k-help from:body -> ermite.dialog('panel-help');
370372 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
371- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
373+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
372374 k-filter from:body -> ermite.focusFilter();
373375 k-gh from:body -> ermite.go('.brand');
374376 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-text.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -194,7 +196,7 @@
194196 <dt>
195197 <kbd>/</kbd>
196198 </dt>
197-<dd>filter the list</dd>
199+<dd>filter the list; on Files, find a file in the repository</dd>
198200 </div>
199201 <div>
200202 <dt>
@@ -281,10 +283,10 @@
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>
287-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
289+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
288290 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
289291 <kbd>esc</kbd>
290292 </button>
@@ -363,7 +365,7 @@
363365 k-esc from:body -> ermite.closeDialogs();
364366 k-help from:body -> ermite.dialog('panel-help');
365367 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
366- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
368+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
367369 k-filter from:body -> ermite.focusFilter();
368370 k-gh from:body -> ermite.go('.brand');
369371 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-too-large.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -174,7 +176,7 @@
174176 <dt>
175177 <kbd>/</kbd>
176178 </dt>
177-<dd>filter the list</dd>
179+<dd>filter the list; on Files, find a file in the repository</dd>
178180 </div>
179181 <div>
180182 <dt>
@@ -261,10 +263,10 @@
261263 </section>
262264 </div>
263265 </dialog>
264-<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()">
266+<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()">
265267 <div class="panel-head">
266268 <label for="cmd-q" class="sr">go to</label>
267-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
269+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
268270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
269271 <kbd>esc</kbd>
270272 </button>
@@ -343,7 +345,7 @@
343345 k-esc from:body -> ermite.closeDialogs();
344346 k-help from:body -> ermite.dialog('panel-help');
345347 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
346- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
348+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
347349 k-filter from:body -> ermite.focusFilter();
348350 k-gh from:body -> ermite.go('.brand');
349351 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-trojan.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -241,7 +243,7 @@
241243 <dt>
242244 <kbd>/</kbd>
243245 </dt>
244-<dd>filter the list</dd>
246+<dd>filter the list; on Files, find a file in the repository</dd>
245247 </div>
246248 <div>
247249 <dt>
@@ -328,10 +330,10 @@
328330 </section>
329331 </div>
330332 </dialog>
331-<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()">
333+<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()">
332334 <div class="panel-head">
333335 <label for="cmd-q" class="sr">go to</label>
334-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
336+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
335337 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
336338 <kbd>esc</kbd>
337339 </button>
@@ -410,7 +412,7 @@
410412 k-esc from:body -> ermite.closeDialogs();
411413 k-help from:body -> ermite.dialog('panel-help');
412414 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
413- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
415+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
414416 k-filter from:body -> ermite.focusFilter();
415417 k-gh from:body -> ermite.go('.brand');
416418 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-big.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -211,7 +213,7 @@
211213 <dt>
212214 <kbd>/</kbd>
213215 </dt>
214-<dd>filter the list</dd>
216+<dd>filter the list; on Files, find a file in the repository</dd>
215217 </div>
216218 <div>
217219 <dt>
@@ -298,10 +300,10 @@
298300 </section>
299301 </div>
300302 </dialog>
301-<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()">
303+<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()">
302304 <div class="panel-head">
303305 <label for="cmd-q" class="sr">go to</label>
304-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
306+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
305307 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
306308 <kbd>esc</kbd>
307309 </button>
@@ -380,7 +382,7 @@
380382 k-esc from:body -> ermite.closeDialogs();
381383 k-help from:body -> ermite.dialog('panel-help');
382384 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
383- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
385+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
384386 k-filter from:body -> ermite.focusFilter();
385387 k-gh from:body -> ermite.go('.brand');
386388 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-binary.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -206,7 +208,7 @@
206208 <dt>
207209 <kbd>/</kbd>
208210 </dt>
209-<dd>filter the list</dd>
211+<dd>filter the list; on Files, find a file in the repository</dd>
210212 </div>
211213 <div>
212214 <dt>
@@ -293,10 +295,10 @@
293295 </section>
294296 </div>
295297 </dialog>
296-<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()">
298+<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()">
297299 <div class="panel-head">
298300 <label for="cmd-q" class="sr">go to</label>
299-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
301+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
300302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
301303 <kbd>esc</kbd>
302304 </button>
@@ -375,7 +377,7 @@
375377 k-esc from:body -> ermite.closeDialogs();
376378 k-help from:body -> ermite.dialog('panel-help');
377379 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
378- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
380+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
379381 k-filter from:body -> ermite.focusFilter();
380382 k-gh from:body -> ermite.go('.brand');
381383 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-merge.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -245,7 +247,7 @@
245247 <dt>
246248 <kbd>/</kbd>
247249 </dt>
248-<dd>filter the list</dd>
250+<dd>filter the list; on Files, find a file in the repository</dd>
249251 </div>
250252 <div>
251253 <dt>
@@ -332,10 +334,10 @@
332334 </section>
333335 </div>
334336 </dialog>
335-<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()">
337+<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()">
336338 <div class="panel-head">
337339 <label for="cmd-q" class="sr">go to</label>
338-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
340+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
339341 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
340342 <kbd>esc</kbd>
341343 </button>
@@ -414,7 +416,7 @@
414416 k-esc from:body -> ermite.closeDialogs();
415417 k-help from:body -> ermite.dialog('panel-help');
416418 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
417- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
419+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
418420 k-filter from:body -> ermite.focusFilter();
419421 k-gh from:body -> ermite.go('.brand');
420422 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-modify-split.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -366,7 +368,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
366368 <dt>
367369 <kbd>/</kbd>
368370 </dt>
369-<dd>filter the list</dd>
371+<dd>filter the list; on Files, find a file in the repository</dd>
370372 </div>
371373 <div>
372374 <dt>
@@ -453,10 +455,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
453455 </section>
454456 </div>
455457 </dialog>
456-<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()">
458+<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()">
457459 <div class="panel-head">
458460 <label for="cmd-q" class="sr">go to</label>
459-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
461+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
460462 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
461463 <kbd>esc</kbd>
462464 </button>
@@ -535,7 +537,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
535537 k-esc from:body -> ermite.closeDialogs();
536538 k-help from:body -> ermite.dialog('panel-help');
537539 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
538- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
540+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
539541 k-filter from:body -> ermite.focusFilter();
540542 k-gh from:body -> ermite.go('.brand');
541543 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-modify.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -366,7 +368,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
366368 <dt>
367369 <kbd>/</kbd>
368370 </dt>
369-<dd>filter the list</dd>
371+<dd>filter the list; on Files, find a file in the repository</dd>
370372 </div>
371373 <div>
372374 <dt>
@@ -453,10 +455,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
453455 </section>
454456 </div>
455457 </dialog>
456-<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()">
458+<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()">
457459 <div class="panel-head">
458460 <label for="cmd-q" class="sr">go to</label>
459-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
461+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
460462 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
461463 <kbd>esc</kbd>
462464 </button>
@@ -535,7 +537,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
535537 k-esc from:body -> ermite.closeDialogs();
536538 k-help from:body -> ermite.dialog('panel-help');
537539 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
538- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
540+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
539541 k-filter from:body -> ermite.focusFilter();
540542 k-gh from:body -> ermite.go('.brand');
541543 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-rename.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -271,7 +273,7 @@
271273 <dt>
272274 <kbd>/</kbd>
273275 </dt>
274-<dd>filter the list</dd>
276+<dd>filter the list; on Files, find a file in the repository</dd>
275277 </div>
276278 <div>
277279 <dt>
@@ -358,10 +360,10 @@
358360 </section>
359361 </div>
360362 </dialog>
361-<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()">
363+<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()">
362364 <div class="panel-head">
363365 <label for="cmd-q" class="sr">go to</label>
364-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
366+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
365367 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
366368 <kbd>esc</kbd>
367369 </button>
@@ -440,7 +442,7 @@
440442 k-esc from:body -> ermite.closeDialogs();
441443 k-help from:body -> ermite.dialog('panel-help');
442444 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
443- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
445+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
444446 k-filter from:body -> ermite.focusFilter();
445447 k-gh from:body -> ermite.go('.brand');
446448 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-root.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -587,7 +589,7 @@
587589 <dt>
588590 <kbd>/</kbd>
589591 </dt>
590-<dd>filter the list</dd>
592+<dd>filter the list; on Files, find a file in the repository</dd>
591593 </div>
592594 <div>
593595 <dt>
@@ -674,10 +676,10 @@
674676 </section>
675677 </div>
676678 </dialog>
677-<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()">
679+<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()">
678680 <div class="panel-head">
679681 <label for="cmd-q" class="sr">go to</label>
680-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
682+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
681683 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
682684 <kbd>esc</kbd>
683685 </button>
@@ -756,7 +758,7 @@
756758 k-esc from:body -> ermite.closeDialogs();
757759 k-help from:body -> ermite.dialog('panel-help');
758760 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
759- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
761+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
760762 k-filter from:body -> ermite.focusFilter();
761763 k-gh from:body -> ermite.go('.brand');
762764 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-trojan.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -380,7 +382,7 @@
380382 <dt>
381383 <kbd>/</kbd>
382384 </dt>
383-<dd>filter the list</dd>
385+<dd>filter the list; on Files, find a file in the repository</dd>
384386 </div>
385387 <div>
386388 <dt>
@@ -467,10 +469,10 @@
467469 </section>
468470 </div>
469471 </dialog>
470-<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()">
472+<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()">
471473 <div class="panel-head">
472474 <label for="cmd-q" class="sr">go to</label>
473-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
475+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
474476 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
475477 <kbd>esc</kbd>
476478 </button>
@@ -549,7 +551,7 @@
549551 k-esc from:body -> ermite.closeDialogs();
550552 k-help from:body -> ermite.dialog('panel-help');
551553 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
552- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
554+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
553555 k-filter from:body -> ermite.focusFilter();
554556 k-gh from:body -> ermite.go('.brand');
555557 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-page2.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -201,7 +203,7 @@
201203 <dt>
202204 <kbd>/</kbd>
203205 </dt>
204-<dd>filter the list</dd>
206+<dd>filter the list; on Files, find a file in the repository</dd>
205207 </div>
206208 <div>
207209 <dt>
@@ -280,10 +282,10 @@
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>
286-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
288+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
287289 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
288290 <kbd>esc</kbd>
289291 </button>
@@ -356,7 +358,7 @@
356358 k-esc from:body -> ermite.closeDialogs();
357359 k-help from:body -> ermite.dialog('panel-help');
358360 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
359- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
361+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
360362 k-filter from:body -> ermite.focusFilter();
361363 k-gh from:body -> ermite.go('.brand');
362364 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-path.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -216,7 +218,7 @@
216218 <dt>
217219 <kbd>/</kbd>
218220 </dt>
219-<dd>filter the list</dd>
221+<dd>filter the list; on Files, find a file in the repository</dd>
220222 </div>
221223 <div>
222224 <dt>
@@ -295,10 +297,10 @@
295297 </section>
296298 </div>
297299 </dialog>
298-<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()">
300+<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()">
299301 <div class="panel-head">
300302 <label for="cmd-q" class="sr">go to</label>
301-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
303+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
302304 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
303305 <kbd>esc</kbd>
304306 </button>
@@ -371,7 +373,7 @@
371373 k-esc from:body -> ermite.closeDialogs();
372374 k-help from:body -> ermite.dialog('panel-help');
373375 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
374- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
376+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
375377 k-filter from:body -> ermite.focusFilter();
376378 k-gh from:body -> ermite.go('.brand');
377379 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-root.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -1005,7 +1007,7 @@
10051007 <dt>
10061008 <kbd>/</kbd>
10071009 </dt>
1008-<dd>filter the list</dd>
1010+<dd>filter the list; on Files, find a file in the repository</dd>
10091011 </div>
10101012 <div>
10111013 <dt>
@@ -1084,10 +1086,10 @@
10841086 </section>
10851087 </div>
10861088 </dialog>
1087-<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()">
1089+<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()">
10881090 <div class="panel-head">
10891091 <label for="cmd-q" class="sr">go to</label>
1090-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
1092+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
10911093 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
10921094 <kbd>esc</kbd>
10931095 </button>
@@ -1160,7 +1162,7 @@
11601162 k-esc from:body -> ermite.closeDialogs();
11611163 k-help from:body -> ermite.dialog('panel-help');
11621164 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
1163- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
1165+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
11641166 k-filter from:body -> ermite.focusFilter();
11651167 k-gh from:body -> ermite.go('.brand');
11661168 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-small.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -350,7 +352,7 @@
350352 <dt>
351353 <kbd>/</kbd>
352354 </dt>
353-<dd>filter the list</dd>
355+<dd>filter the list; on Files, find a file in the repository</dd>
354356 </div>
355357 <div>
356358 <dt>
@@ -437,10 +439,10 @@
437439 </section>
438440 </div>
439441 </dialog>
440-<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()">
442+<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()">
441443 <div class="panel-head">
442444 <label for="cmd-q" class="sr">go to</label>
443-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
445+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
444446 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
445447 <kbd>esc</kbd>
446448 </button>
@@ -525,7 +527,7 @@
525527 k-esc from:body -> ermite.closeDialogs();
526528 k-help from:body -> ermite.dialog('panel-help');
527529 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
528- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
530+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
529531 k-filter from:body -> ermite.focusFilter();
530532 k-gh from:body -> ermite.go('.brand');
531533 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/empty.html +6 -4
@@ -17,6 +17,8 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
2123 <body data-ref="" data-sha="" data-sel="" data-nav="">
2224 <a class="skip" href="#main">skip to content</a>
@@ -127,7 +129,7 @@
127129 <dt>
128130 <kbd>/</kbd>
129131 </dt>
130-<dd>filter the list</dd>
132+<dd>filter the list; on Files, find a file in the repository</dd>
131133 </div>
132134 <div>
133135 <dt>
@@ -179,10 +181,10 @@
179181 </div>
180182 </dl>
181183 </dialog>
182-<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()">
184+<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()">
183185 <div class="panel-head">
184186 <label for="cmd-q" class="sr">go to</label>
185-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
187+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
186188 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
187189 <kbd>esc</kbd>
188190 </button>
@@ -219,7 +221,7 @@
219221 k-esc from:body -> ermite.closeDialogs();
220222 k-help from:body -> ermite.dialog('panel-help');
221223 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
222- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
224+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
223225 k-filter from:body -> ermite.focusFilter();
224226 k-gh from:body -> ermite.go('.brand');
225227 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/home.html +6 -4
@@ -17,6 +17,8 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
2123 <body data-ref="" data-sha="" data-sel="" data-nav="">
2224 <a class="skip" href="#main">skip to content</a>
@@ -159,7 +161,7 @@
159161 <dt>
160162 <kbd>/</kbd>
161163 </dt>
162-<dd>filter the list</dd>
164+<dd>filter the list; on Files, find a file in the repository</dd>
163165 </div>
164166 <div>
165167 <dt>
@@ -211,10 +213,10 @@
211213 </div>
212214 </dl>
213215 </dialog>
214-<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()">
216+<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()">
215217 <div class="panel-head">
216218 <label for="cmd-q" class="sr">go to</label>
217-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
219+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
218220 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
219221 <kbd>esc</kbd>
220222 </button>
@@ -257,7 +259,7 @@
257259 k-esc from:body -> ermite.closeDialogs();
258260 k-help from:body -> ermite.dialog('panel-help');
259261 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
260- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
262+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
261263 k-filter from:body -> ermite.focusFilter();
262264 k-gh from:body -> ermite.go('.brand');
263265 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/not-found-page.html +6 -4
@@ -17,6 +17,8 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
2123 <body data-ref="" data-sha="" data-sel="" data-nav="">
2224 <a class="skip" href="#main">skip to content</a>
@@ -116,7 +118,7 @@
116118 <dt>
117119 <kbd>/</kbd>
118120 </dt>
119-<dd>filter the list</dd>
121+<dd>filter the list; on Files, find a file in the repository</dd>
120122 </div>
121123 <div>
122124 <dt>
@@ -168,10 +170,10 @@
168170 </div>
169171 </dl>
170172 </dialog>
171-<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()">
173+<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()">
172174 <div class="panel-head">
173175 <label for="cmd-q" class="sr">go to</label>
174-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
176+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
175177 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
176178 <kbd>esc</kbd>
177179 </button>
@@ -202,7 +204,7 @@
202204 k-esc from:body -> ermite.closeDialogs();
203205 k-help from:body -> ermite.dialog('panel-help');
204206 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
205- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
207+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
206208 k-filter from:body -> ermite.focusFilter();
207209 k-gh from:body -> ermite.go('.brand');
208210 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/not-found.html +6 -4
@@ -17,6 +17,8 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
2123 <body data-ref="" data-sha="" data-sel="" data-nav="">
2224 <a class="skip" href="#main">skip to content</a>
@@ -116,7 +118,7 @@
116118 <dt>
117119 <kbd>/</kbd>
118120 </dt>
119-<dd>filter the list</dd>
121+<dd>filter the list; on Files, find a file in the repository</dd>
120122 </div>
121123 <div>
122124 <dt>
@@ -168,10 +170,10 @@
168170 </div>
169171 </dl>
170172 </dialog>
171-<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()">
173+<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()">
172174 <div class="panel-head">
173175 <label for="cmd-q" class="sr">go to</label>
174-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
176+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
175177 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
176178 <kbd>esc</kbd>
177179 </button>
@@ -202,7 +204,7 @@
202204 k-esc from:body -> ermite.closeDialogs();
203205 k-help from:body -> ermite.dialog('panel-help');
204206 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
205- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
207+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
206208 k-filter from:body -> ermite.focusFilter();
207209 k-gh from:body -> ermite.go('.brand');
208210 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/refs.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -242,7 +244,7 @@
242244 <dt>
243245 <kbd>/</kbd>
244246 </dt>
245-<dd>filter the list</dd>
247+<dd>filter the list; on Files, find a file in the repository</dd>
246248 </div>
247249 <div>
248250 <dt>
@@ -333,10 +335,10 @@
333335 </section>
334336 </div>
335337 </dialog>
336-<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()">
338+<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()">
337339 <div class="panel-head">
338340 <label for="cmd-q" class="sr">go to</label>
339-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
341+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
340342 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
341343 <kbd>esc</kbd>
342344 </button>
@@ -427,7 +429,7 @@
427429 k-esc from:body -> ermite.closeDialogs();
428430 k-help from:body -> ermite.dialog('panel-help');
429431 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
430- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
432+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
431433 k-filter from:body -> ermite.focusFilter();
432434 k-gh from:body -> ermite.go('.brand');
433435 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/summary.html +7 -5
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -261,7 +263,7 @@
261263 <dt>
262264 <kbd>/</kbd>
263265 </dt>
264-<dd>filter the list</dd>
266+<dd>filter the list; on Files, find a file in the repository</dd>
265267 </div>
266268 <div>
267269 <dt>
@@ -352,10 +354,10 @@
352354 </section>
353355 </div>
354356 </dialog>
355-<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()">
357+<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()">
356358 <div class="panel-head">
357359 <label for="cmd-q" class="sr">go to</label>
358-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
360+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
359361 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
360362 <kbd>esc</kbd>
361363 </button>
@@ -446,7 +448,7 @@
446448 k-esc from:body -> ermite.closeDialogs();
447449 k-help from:body -> ermite.dialog('panel-help');
448450 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
449- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
451+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
450452 k-filter from:body -> ermite.focusFilter();
451453 k-gh from:body -> ermite.go('.brand');
452454 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-nested.html +18 -14
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -90,9 +92,16 @@
9092 <div class="work">
9193 <main id="main" tabindex="-1">
9294 <div class="filter">
93-<label for="tree-filter" class="sr">filter</label>
94-<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
95+<label for="finder-q" class="sr">find a file</label>
96+<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)">
97+</div>
98+<div id="finder" hidden>
99+<p id="finder-status" class="dim" aria-live="polite">
100+</p>
101+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
102+</ul>
95103 </div>
104+<div id="tree-view">
96105 <table class="list tree" aria-label="Entries">
97106 <thead>
98107 <tr>
@@ -107,7 +116,7 @@
107116 <th class="c-when">date</th>
108117 </tr>
109118 </thead>
110-<tbody id="tree-rows" data-rows>
119+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
111120 <tr data-cursor>
112121 <td class="cur" aria-hidden="true">
113122 </td>
@@ -126,6 +135,7 @@
126135 </tr>
127136 </tbody>
128137 </table>
138+</div>
129139 </main>
130140 </div>
131141 <footer class="foot">
@@ -186,7 +196,7 @@
186196 <dt>
187197 <kbd>/</kbd>
188198 </dt>
189-<dd>filter the list</dd>
199+<dd>filter the list; on Files, find a file in the repository</dd>
190200 </div>
191201 <div>
192202 <dt>
@@ -273,10 +283,10 @@
273283 </section>
274284 </div>
275285 </dialog>
276-<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()">
277287 <div class="panel-head">
278288 <label for="cmd-q" class="sr">go to</label>
279-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
289+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
280290 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
281291 <kbd>esc</kbd>
282292 </button>
@@ -336,12 +346,6 @@
336346 <span class="k">tag</span>
337347 </a>
338348 </li>
339-<li data-k="file src/lib/util.go">
340-<a href="/files/blob/main/src/lib/util.go" tabindex="-1">
341-<span class="lbl">src/lib/util.go</span>
342-<span class="k">file</span>
343-</a>
344-</li>
345349 </ul>
346350 <p class="panel-foot">
347351 <span>
@@ -361,7 +365,7 @@
361365 k-esc from:body -> ermite.closeDialogs();
362366 k-help from:body -> ermite.dialog('panel-help');
363367 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
364- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
368+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
365369 k-filter from:body -> ermite.focusFilter();
366370 k-gh from:body -> ermite.go('.brand');
367371 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-root.html +22 -90
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -84,9 +86,16 @@
8486 <div class="work">
8587 <main id="main" tabindex="-1">
8688 <div class="filter">
87-<label for="tree-filter" class="sr">filter</label>
88-<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
89+<label for="finder-q" class="sr">find a file</label>
90+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
91+</div>
92+<div id="finder" hidden>
93+<p id="finder-status" class="dim" aria-live="polite">
94+</p>
95+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
96+</ul>
8997 </div>
98+<div id="tree-view">
9099 <table class="list tree" aria-label="Entries">
91100 <thead>
92101 <tr>
@@ -101,13 +110,13 @@
101110 <th class="c-when">date</th>
102111 </tr>
103112 </thead>
104-<tbody id="tree-rows" data-rows>
113+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
105114 <tr data-cursor>
106115 <td class="cur" aria-hidden="true">
107116 </td>
108-<td class="c-kind" aria-hidden="true">▾</td>
117+<td class="c-kind" aria-hidden="true">▸</td>
109118 <td class="c-name">
110-<a href="/files/tree/main/docs">docs/</a>
119+<a href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false">docs/</a>
111120 </td>
112121 <td class="c-size num dim">-</td>
113122 <td class="c-sub dim">
@@ -121,9 +130,9 @@
121130 <tr>
122131 <td class="cur" aria-hidden="true">
123132 </td>
124-<td class="c-kind" aria-hidden="true">▾</td>
133+<td class="c-kind" aria-hidden="true">▸</td>
125134 <td class="c-name">
126-<a href="/files/tree/main/src">src/</a>
135+<a href="/files/tree/main/src" data-expand="/files/tree/main/src?rows" aria-expanded="false">src/</a>
127136 </td>
128137 <td class="c-size num dim">-</td>
129138 <td class="c-sub dim">
@@ -353,6 +362,7 @@
353362 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
354363 </article>
355364 </section>
365+</div>
356366 </main>
357367 </div>
358368 <footer class="foot">
@@ -413,7 +423,7 @@
413423 <dt>
414424 <kbd>/</kbd>
415425 </dt>
416-<dd>filter the list</dd>
426+<dd>filter the list; on Files, find a file in the repository</dd>
417427 </div>
418428 <div>
419429 <dt>
@@ -500,10 +510,10 @@
500510 </section>
501511 </div>
502512 </dialog>
503-<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()">
513+<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()">
504514 <div class="panel-head">
505515 <label for="cmd-q" class="sr">go to</label>
506-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
516+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
507517 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
508518 <kbd>esc</kbd>
509519 </button>
@@ -563,84 +573,6 @@
563573 <span class="k">tag</span>
564574 </a>
565575 </li>
566-<li data-k="file readme.md">
567-<a href="/files/blob/main/README.md" tabindex="-1">
568-<span class="lbl">README.md</span>
569-<span class="k">file</span>
570-</a>
571-</li>
572-<li data-k="file big.txt">
573-<a href="/files/blob/main/big.txt" tabindex="-1">
574-<span class="lbl">big.txt</span>
575-<span class="k">file</span>
576-</a>
577-</li>
578-<li data-k="file data.bin">
579-<a href="/files/blob/main/data.bin" tabindex="-1">
580-<span class="lbl">data.bin</span>
581-<span class="k">file</span>
582-</a>
583-</li>
584-<li data-k="file empty.txt">
585-<a href="/files/blob/main/empty.txt" tabindex="-1">
586-<span class="lbl">empty.txt</span>
587-<span class="k">file</span>
588-</a>
589-</li>
590-<li data-k="file evil.go">
591-<a href="/files/blob/main/evil.go" tabindex="-1">
592-<span class="lbl">evil.go</span>
593-<span class="k">file</span>
594-</a>
595-</li>
596-<li data-k="file fake.jpg">
597-<a href="/files/blob/main/fake.jpg" tabindex="-1">
598-<span class="lbl">fake.jpg</span>
599-<span class="k">file</span>
600-</a>
601-</li>
602-<li data-k="file fake.png">
603-<a href="/files/blob/main/fake.png" tabindex="-1">
604-<span class="lbl">fake.png</span>
605-<span class="k">file</span>
606-</a>
607-</li>
608-<li data-k="file lines.txt">
609-<a href="/files/blob/main/lines.txt" tabindex="-1">
610-<span class="lbl">lines.txt</span>
611-<span class="k">file</span>
612-</a>
613-</li>
614-<li data-k="file logo.svg">
615-<a href="/files/blob/main/logo.svg" tabindex="-1">
616-<span class="lbl">logo.svg</span>
617-<span class="k">file</span>
618-</a>
619-</li>
620-<li data-k="file main.go">
621-<a href="/files/blob/main/main.go" tabindex="-1">
622-<span class="lbl">main.go</span>
623-<span class="k">file</span>
624-</a>
625-</li>
626-<li data-k="file notes.txt">
627-<a href="/files/blob/main/notes.txt" tabindex="-1">
628-<span class="lbl">notes.txt</span>
629-<span class="k">file</span>
630-</a>
631-</li>
632-<li data-k="file page.html">
633-<a href="/files/blob/main/page.html" tabindex="-1">
634-<span class="lbl">page.html</span>
635-<span class="k">file</span>
636-</a>
637-</li>
638-<li data-k="file pic.png">
639-<a href="/files/blob/main/pic.png" tabindex="-1">
640-<span class="lbl">pic.png</span>
641-<span class="k">file</span>
642-</a>
643-</li>
644576 </ul>
645577 <p class="panel-foot">
646578 <span>
@@ -660,7 +592,7 @@
660592 k-esc from:body -> ermite.closeDialogs();
661593 k-help from:body -> ermite.dialog('panel-help');
662594 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
663- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
595+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
664596 k-filter from:body -> ermite.focusFilter();
665597 k-gh from:body -> ermite.go('.brand');
666598 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-slash-ref.html +18 -14
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -87,9 +89,16 @@
8789 <div class="work">
8890 <main id="main" tabindex="-1">
8991 <div class="filter">
90-<label for="tree-filter" class="sr">filter</label>
91-<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
92+<label for="finder-q" class="sr">find a file</label>
93+<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)">
94+</div>
95+<div id="finder" hidden>
96+<p id="finder-status" class="dim" aria-live="polite">
97+</p>
98+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
99+</ul>
92100 </div>
101+<div id="tree-view">
93102 <table class="list tree" aria-label="Entries">
94103 <thead>
95104 <tr>
@@ -104,7 +113,7 @@
104113 <th class="c-when">date</th>
105114 </tr>
106115 </thead>
107-<tbody id="tree-rows" data-rows>
116+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
108117 <tr data-cursor>
109118 <td class="cur" aria-hidden="true">
110119 </td>
@@ -123,6 +132,7 @@
123132 </tr>
124133 </tbody>
125134 </table>
135+</div>
126136 </main>
127137 </div>
128138 <footer class="foot">
@@ -183,7 +193,7 @@
183193 <dt>
184194 <kbd>/</kbd>
185195 </dt>
186-<dd>filter the list</dd>
196+<dd>filter the list; on Files, find a file in the repository</dd>
187197 </div>
188198 <div>
189199 <dt>
@@ -270,10 +280,10 @@
270280 </section>
271281 </div>
272282 </dialog>
273-<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()">
283+<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()">
274284 <div class="panel-head">
275285 <label for="cmd-q" class="sr">go to</label>
276-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
286+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
277287 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
278288 <kbd>esc</kbd>
279289 </button>
@@ -333,12 +343,6 @@
333343 <span class="k">tag</span>
334344 </a>
335345 </li>
336-<li data-k="file x/y.txt">
337-<a href="/files/blob/feature/x/x/y.txt" tabindex="-1">
338-<span class="lbl">x/y.txt</span>
339-<span class="k">file</span>
340-</a>
341-</li>
342346 </ul>
343347 <p class="panel-foot">
344348 <span>
@@ -358,7 +362,7 @@
358362 k-esc from:body -> ermite.closeDialogs();
359363 k-help from:body -> ermite.dialog('panel-help');
360364 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
361- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
365+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
362366 k-filter from:body -> ermite.focusFilter();
363367 k-gh from:body -> ermite.go('.brand');
364368 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-subdir.html +18 -20
@@ -17,8 +17,10 @@
1717 </script>
1818 <script src="/-/static/js/ermite.HASH.js" defer>
1919 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
2022 </head>
21-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
23+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2224 <a class="skip" href="#main">skip to content</a>
2325 <header class="hdr">
2426 <div class="hdr-top">
@@ -87,9 +89,16 @@
8789 <div class="work">
8890 <main id="main" tabindex="-1">
8991 <div class="filter">
90-<label for="tree-filter" class="sr">filter</label>
91-<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
92+<label for="finder-q" class="sr">find a file</label>
93+<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)">
9294 </div>
95+<div id="finder" hidden>
96+<p id="finder-status" class="dim" aria-live="polite">
97+</p>
98+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
99+</ul>
100+</div>
101+<div id="tree-view">
93102 <table class="list tree" aria-label="Entries">
94103 <thead>
95104 <tr>
@@ -104,7 +113,7 @@
104113 <th class="c-when">date</th>
105114 </tr>
106115 </thead>
107-<tbody id="tree-rows" data-rows>
116+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
108117 <tr data-cursor>
109118 <td class="cur" aria-hidden="true">
110119 </td>
@@ -148,6 +157,7 @@
148157 <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>
149158 </article>
150159 </section>
160+</div>
151161 </main>
152162 </div>
153163 <footer class="foot">
@@ -208,7 +218,7 @@
208218 <dt>
209219 <kbd>/</kbd>
210220 </dt>
211-<dd>filter the list</dd>
221+<dd>filter the list; on Files, find a file in the repository</dd>
212222 </div>
213223 <div>
214224 <dt>
@@ -295,10 +305,10 @@
295305 </section>
296306 </div>
297307 </dialog>
298-<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()">
308+<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()">
299309 <div class="panel-head">
300310 <label for="cmd-q" class="sr">go to</label>
301-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
311+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
302312 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
303313 <kbd>esc</kbd>
304314 </button>
@@ -358,18 +368,6 @@
358368 <span class="k">tag</span>
359369 </a>
360370 </li>
361-<li data-k="file docs/readme.md">
362-<a href="/files/blob/main/docs/README.md" tabindex="-1">
363-<span class="lbl">docs/README.md</span>
364-<span class="k">file</span>
365-</a>
366-</li>
367-<li data-k="file docs/guide.md">
368-<a href="/files/blob/main/docs/guide.md" tabindex="-1">
369-<span class="lbl">docs/guide.md</span>
370-<span class="k">file</span>
371-</a>
372-</li>
373371 </ul>
374372 <p class="panel-foot">
375373 <span>
@@ -389,7 +387,7 @@
389387 k-esc from:body -> ermite.closeDialogs();
390388 k-help from:body -> ermite.dialog('panel-help');
391389 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
392- k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
390+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
393391 k-filter from:body -> ermite.focusFilter();
394392 k-gh from:body -> ermite.go('.brand');
395393 k-s from:body -> ermite.tab('s');
M internal/app/web_files_test.go +255 -0
@@ -4,10 +4,16 @@ package app
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(), ports.CreateRepoParams{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"), ports.UpdateRepoParams{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 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
@@ -77,10 +77,79 @@ func TestParseTreePath(t *testing.T) {
7777 require.NoError(t, err)
7878 assert.Equal(t, "a/b", p.String())
7979 assert.Equal(t, "a/b/c", p.Join("c"))
80+}
81+
82+// ParseTreePath and IsAddressablePath share one rule. The only differences
83+// are the root ("" is a valid path to parse, and not addressable) and the
84+// trailing slash (ParseTreePath drops one, IsAddressablePath wants the path
85+// as it is).
86+func TestTreePathRule(t *testing.T) {
87+ long := strings.Repeat("a", 4096)
88+ for _, tt := range []struct {
89+ path string
90+ ok bool // parses, and is addressable
91+ }{
92+ {"a", true}, {"a/b.txt", true}, {"dir/ünï.go", true}, {"a b/c", true}, {".github/ci.yml", true}, {long, true},
93+ {"/a", false}, {"a//b", false}, {"./a", false}, {"a/..", false}, {"..", false}, {".", false},
94+ {"a/../b", false}, {"tab\tname", false}, {"a\nb", false}, {"a\x00b", false}, {"a\x7fb", false},
95+ {"\xff", false}, {"a/\xfe/b", false}, {long + "a", false}, {"//", false},
96+ } {
97+ _, err := domain.ParseTreePath(tt.path)
98+ assert.Equal(t, tt.ok, err == nil, "ParseTreePath(%q)", tt.path)
99+ if !tt.ok {
100+ assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", tt.path)
101+ }
102+ assert.Equal(t, tt.ok, domain.IsAddressablePath(tt.path), "IsAddressablePath(%q)", tt.path)
103+ assert.Equal(t, tt.ok, domain.IsAddressablePathBytes([]byte(tt.path)), "IsAddressablePathBytes(%q)", tt.path)
104+ }
105+
106+ // The exceptions.
107+ _, err := domain.ParseTreePath("")
108+ assert.NoError(t, err, "the root parses")
109+ assert.False(t, domain.IsAddressablePath(""), "the root is not addressable")
110+ assert.False(t, domain.IsAddressablePathBytes(nil))
111+ _, err = domain.ParseTreePath("a/")
112+ assert.NoError(t, err, "one trailing slash is dropped")
113+ assert.False(t, domain.IsAddressablePath("a/"), "an addressable path has none")
114+ assert.False(t, domain.IsAddressablePathBytes([]byte("a/")))
115+ _, err = domain.ParseTreePath("/")
116+ assert.NoError(t, err, `"/" is the root with its trailing slash dropped`)
117+ assert.False(t, domain.IsAddressablePath("/"))
118+}
80119
81- for _, bad := range []string{"/a", "a//b", "./a", "a/..", "..", ".", "a\x00b", "a\nb", "\xff", strings.Repeat("a", 4097), "//"} {
82- _, err := domain.ParseTreePath(bad)
83- assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", bad)
120+func TestPathListAdd(t *testing.T) {
121+ for _, tt := range []struct {
122+ name string
123+ adds []string
124+ maxPaths, maxBytes int
125+ want []string
126+ wantStopAt int // index of the Add that returned false, -1 for none
127+ truncated bool
128+ }{
129+ {"all fit", []string{"a", "b/c"}, 10, 100, []string{"a", "b/c"}, -1, false},
130+ {"not addressable are skipped", []string{"a", "", "b//c", "d/", "e\nf", "g"}, 10, 100, []string{"a", "g"}, -1, false},
131+ {"skipped paths do not count", []string{"", "..", "a"}, 1, 1, []string{"a"}, -1, false},
132+ {"exactly maxPaths", []string{"a", "b"}, 2, 100, []string{"a", "b"}, -1, false},
133+ {"over maxPaths", []string{"a", "b", "c"}, 2, 100, []string{"a", "b"}, 2, true},
134+ {"exactly maxBytes", []string{"ab", "cd"}, 10, 4, []string{"ab", "cd"}, -1, false},
135+ {"over maxBytes", []string{"ab", "cde", "f"}, 10, 4, []string{"ab"}, 1, true},
136+ {"nothing fits", []string{"abc"}, 10, 2, nil, 0, true},
137+ {"no paths allowed", []string{"a"}, 0, 100, nil, 0, true},
138+ {"unaddressable past the limit is no truncation", []string{"a", "b/"}, 1, 100, []string{"a"}, -1, false},
139+ } {
140+ t.Run(tt.name, func(t *testing.T) {
141+ var l domain.PathList
142+ stop := -1
143+ for i, p := range tt.adds {
144+ if !l.Add(p, tt.maxPaths, tt.maxBytes) {
145+ stop = i
146+ break
147+ }
148+ }
149+ assert.Equal(t, tt.want, l.Paths)
150+ assert.Equal(t, tt.wantStopAt, stop)
151+ assert.Equal(t, tt.truncated, l.Truncated)
152+ })
84153 }
85154 }
86155
M internal/ports/mocks/BrowseService.go +78 -0
@@ -295,6 +295,84 @@ func (_c *BrowseService_ListCommits_Call) RunAndReturn(run func(ctx context.Cont
295295 return _c
296296 }
297297
298+// ListPaths provides a mock function for the type BrowseService
299+func (_mock *BrowseService) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) {
300+ ret := _mock.Called(ctx, p, repo, rev)
301+
302+ if len(ret) == 0 {
303+ panic("no return value specified for ListPaths")
304+ }
305+
306+ var r0 domain.PathList
307+ var r1 error
308+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.PathList, error)); ok {
309+ return returnFunc(ctx, p, repo, rev)
310+ }
311+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.PathList); ok {
312+ r0 = returnFunc(ctx, p, repo, rev)
313+ } else {
314+ r0 = ret.Get(0).(domain.PathList)
315+ }
316+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {
317+ r1 = returnFunc(ctx, p, repo, rev)
318+ } else {
319+ r1 = ret.Error(1)
320+ }
321+ return r0, r1
322+}
323+
324+// BrowseService_ListPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPaths'
325+type BrowseService_ListPaths_Call struct {
326+ *mock.Call
327+}
328+
329+// ListPaths is a helper method to define mock.On call
330+// - ctx context.Context
331+// - p domain.Principal
332+// - repo domain.RepoName
333+// - rev domain.Revision
334+func (_e *BrowseService_Expecter) ListPaths(ctx any, p any, repo any, rev any) *BrowseService_ListPaths_Call {
335+ return &BrowseService_ListPaths_Call{Call: _e.mock.On("ListPaths", ctx, p, repo, rev)}
336+}
337+
338+func (_c *BrowseService_ListPaths_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_ListPaths_Call {
339+ _c.Call.Run(func(args mock.Arguments) {
340+ var arg0 context.Context
341+ if args[0] != nil {
342+ arg0 = args[0].(context.Context)
343+ }
344+ var arg1 domain.Principal
345+ if args[1] != nil {
346+ arg1 = args[1].(domain.Principal)
347+ }
348+ var arg2 domain.RepoName
349+ if args[2] != nil {
350+ arg2 = args[2].(domain.RepoName)
351+ }
352+ var arg3 domain.Revision
353+ if args[3] != nil {
354+ arg3 = args[3].(domain.Revision)
355+ }
356+ run(
357+ arg0,
358+ arg1,
359+ arg2,
360+ arg3,
361+ )
362+ })
363+ return _c
364+}
365+
366+func (_c *BrowseService_ListPaths_Call) Return(pathList domain.PathList, err error) *BrowseService_ListPaths_Call {
367+ _c.Call.Return(pathList, err)
368+ return _c
369+}
370+
371+func (_c *BrowseService_ListPaths_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)) *BrowseService_ListPaths_Call {
372+ _c.Call.Return(run)
373+ return _c
374+}
375+
298376 // ListRefs provides a mock function for the type BrowseService
299377 func (_mock *BrowseService) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) {
300378 ret := _mock.Called(ctx, p, repo, params)
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
@@ -163,6 +163,11 @@ type RepoReader interface {
163163 // domain.MaxLastCommitEntries entries get their last commit. It returns
164164 // domain.ErrNotDirectory if path names something else.
165165 GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)
166+ // ListPaths lists the file and symlink paths of rev recursively in tree
167+ // order, at most maxPaths paths and maxBytes bytes of paths (PathList.Truncated
168+ // tells), skipping submodules and paths that are not addressable
169+ // (domain.IsAddressablePath).
170+ ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error)
166171 // OpenBlob returns the description of the file at path and a reader of its
167172 // content, which streams without holding the whole file in memory. The
168173 // caller must close the reader. It returns domain.ErrNotFile if path names
M internal/ports/services.go +1 -0
@@ -61,6 +61,7 @@ type BrowseService interface {
6161 ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error)
6262 ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)
6363 GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)
64+ ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
6465 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
6566 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
6667 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
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 {
M internal/usecase/browse.go +9 -0
@@ -203,6 +203,15 @@ func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.Re
203203 return tree, nil
204204 }
205205
206+// ListPaths lists the paths of every file and symbolic link of a revision, at
207+// most domain.MaxPathEntries paths and domain.MaxPathBytes bytes of paths.
208+func (b *Browse) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) {
209+ if _, err := b.authorize(ctx, p, repo); err != nil {
210+ return domain.PathList{}, err
211+ }
212+ return b.reader.ListPaths(ctx, repo, rev, domain.MaxPathEntries, domain.MaxPathBytes)
213+}
214+
206215 // OpenBlob returns the description of a file and a reader of its content. The
207216 // caller closes the reader.
208217 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/usecase/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 *usecase.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 *usecase.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 {

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 45

↑↓ moveenter openesc close