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:
36 steps:36 steps:
37 # openssh provides ssh-keygen, which the tests use to check that the squash37 # openssh provides ssh-keygen, which the tests use to check that the squash
38 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.38 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.
39+ # nodejs runs the tests of the file finder (internal/adapters/web/jstest).
39 - name: System packages40 - name: System packages
40- run: pacman -Syu --noconfirm --needed git gcc openssh41+ run: pacman -Syu --noconfirm --needed git gcc openssh nodejs
41 - uses: actions/checkout@v642 - uses: actions/checkout@v6
42 # Job containers reach Forgejo through the Gordon edge on the host loopback,43 # Job containers reach Forgejo through the Gordon edge on the host loopback,
43 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).44 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).
@@ -66,6 +67,9 @@ jobs:
66 - run: go test -race ./...67 - run: go test -race ./...
67 - name: Architecture68 - name: Architecture
68 run: go tool hexcheck -fail-on warn ./...69 run: go tool hexcheck -fail-on warn ./...
70+ # The core of the file finder (static/js/files.js) runs in Node.
71+ - name: JavaScript tests
72+ run: node --test 'internal/adapters/web/jstest/*.test.mjs'
69 73
70 proto:74 proto:
71 name: Protobuf and generated code75 name: Protobuf and generated code
M README.md +1 -1
@@ -2,7 +2,7 @@
2 2
3 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates.3 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates.
4 4
5-Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), the API, `GET /-/health`, and the web: repositories, tree, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge.5+Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), the API, `GET /-/health`, and the web: repositories, an expandable file tree with a repository-wide fuzzy file finder, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge.
6 6
7 ## Run7 ## Run
8 8
M go.mod +1 -1
@@ -12,7 +12,7 @@ tool (
12 )12 )
13 13
14 require (14 require (
15- bnema.dev/ermite-api v0.0.0-20261011052945-e9394b717bc215+ bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6
16 bnema.dev/ermite-git v0.1.016 bnema.dev/ermite-git v0.1.0
17 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c317 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
18 connectrpc.com/connect v1.21.018 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/ermitegit/bench_test.go +4 -0
@@ -163,6 +163,10 @@ func BenchmarkRepoReader(b *testing.B) {
163 _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)163 _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)
164 return164 return
165 }},165 }},
166+ {"ListPaths", func(r ports.RepoReader) (err error) {
167+ _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
168+ return
169+ }},
166 {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},170 {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},
167 {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},171 {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},
168 {"ListCommits/page1", func(r ports.RepoReader) (err error) {172 {"ListCommits/page1", func(r ports.RepoReader) (err error) {
M internal/adapters/ermitegit/convert.go +98 -0
@@ -8,6 +8,7 @@ import (
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10 "io"10 "io"
11+ "strings"
11 "time"12 "time"
12 13
13 git "bnema.dev/ermite-git"14 git "bnema.dev/ermite-git"
@@ -326,6 +327,103 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.
326 return tree, nil327 return tree, nil
327 }328 }
328 329
330+// pathChunk is the size of the blocks that hold the bytes of the paths.
331+const pathChunk = 32 << 10
332+
333+// pathWalker lists the files below a tree, depth first, in tree order.
334+type pathWalker struct {
335+ ctx context.Context
336+ r *git.Repo
337+ maxPaths int
338+ maxBytes int
339+
340+ // list collects the paths. Their bytes live in chunks: a chunk is filled
341+ // by a strings.Builder, so a path is read back as a string without a copy,
342+ // and a full chunk is left to the paths that point into it instead of
343+ // being grown and copied.
344+ list domain.PathList
345+ chunk strings.Builder
346+ prefix []byte // "dir/sub/" of the directory being read
347+ bad error
348+}
349+
350+// listPaths lists the files and symbolic links of a revision recursively.
351+func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
352+ commit, root, err := commitTree(r, rev)
353+ if err != nil {
354+ return domain.PathList{}, err
355+ }
356+ treeID, err := domainID(root)
357+ if err != nil {
358+ return domain.PathList{}, err
359+ }
360+ w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes}
361+ w.list.CommitID, w.list.TreeID = commit, treeID
362+ if err := w.walk(root); err != nil {
363+ return domain.PathList{}, err
364+ }
365+ return w.list, nil
366+}
367+
368+// walk lists one directory. The recursion is inside the callback of ReadTree,
369+// where the entries of a subdirectory come at the place of the subdirectory,
370+// like in `git ls-tree -r`; the callback can only say stop, so the reasons to
371+// stop are kept in the walker. Every ReadTree takes its own pooled buffer, so
372+// the nesting is safe.
373+func (w *pathWalker) walk(tree git.ObjectID) error {
374+ if err := w.ctx.Err(); err != nil {
375+ return err
376+ }
377+ err := w.r.ReadTree(tree, func(e git.Entry) bool {
378+ switch {
379+ case e.Mode.IsGitlink():
380+ return true
381+ case e.Mode.IsTree():
382+ mark := len(w.prefix)
383+ w.prefix = append(w.prefix, e.Name...)
384+ // The paths below a directory that is not addressable are not
385+ // either: this also bounds the depth of the recursion.
386+ if !domain.IsAddressablePathBytes(w.prefix) {
387+ w.prefix = w.prefix[:mark]
388+ return true
389+ }
390+ w.prefix = append(w.prefix, '/')
391+ err := w.walk(e.ID)
392+ w.prefix = w.prefix[:mark]
393+ if err != nil {
394+ w.bad = err
395+ return false
396+ }
397+ return !w.list.Truncated
398+ case e.Mode.IsRegular(), e.Mode.IsSymlink():
399+ return w.add(e.Name)
400+ }
401+ w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
402+ return false
403+ })
404+ if err == nil {
405+ err = w.bad
406+ }
407+ return err
408+}
409+
410+// add lists the file name of the current directory, and says whether the walk
411+// goes on. PathList.Add filters the paths that are not addressable; their
412+// bytes stay in the chunk, which is rare enough not to matter.
413+func (w *pathWalker) add(name []byte) bool {
414+ mark := len(w.prefix)
415+ w.prefix = append(w.prefix, name...)
416+ path := w.prefix
417+ w.prefix = w.prefix[:mark]
418+ if w.chunk.Cap()-w.chunk.Len() < len(path) {
419+ w.chunk = strings.Builder{}
420+ w.chunk.Grow(max(pathChunk, len(path)))
421+ }
422+ start := w.chunk.Len()
423+ w.chunk.Write(path)
424+ return w.list.Add(w.chunk.String()[start:], w.maxPaths, w.maxBytes)
425+}
426+
329 // fillLastCommits sets the last commit of the first MaxLastCommitEntries427 // fillLastCommits sets the last commit of the first MaxLastCommitEntries
330 // entries with one walk.428 // entries with one walk.
331 //429 //
M internal/adapters/ermitegit/ermitegit.go +18 -1
@@ -98,6 +98,8 @@ type Routes struct {
98 ListCommits Backend98 ListCommits Backend
99 // ListCommitsPath is a page of the history of a file or a directory.99 // ListCommitsPath is a page of the history of a file or a directory.
100 ListCommitsPath Backend100 ListCommitsPath Backend
101+ // ListPaths is the recursive list of the file paths of a revision.
102+ ListPaths Backend
101 }103 }
102 104
103 // tree picks the route of a directory listing.105 // tree picks the route of a directory listing.
@@ -115,7 +117,7 @@ func (r Routes) tree(path domain.TreePath, lastCommits bool) Backend {
115 // AllRoutes returns routes that send every method to b. The tests and the117 // AllRoutes returns routes that send every method to b. The tests and the
116 // benchmarks use it to exercise a backend whatever the production routes are.118 // benchmarks use it to exercise a backend whatever the production routes are.
117 func AllRoutes(b Backend) Routes {119 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}
119 }121 }
120 122
121 // Config configures an Adapter.123 // Config configures an Adapter.
@@ -331,6 +333,21 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path
331 }, cli)333 }, cli)
332 }334 }
333 335
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+
334 // GetTree lists a directory.351 // GetTree lists a directory.
335 func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {352 func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
336 cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) }353 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
16 // a method has several shapes that behave differently, each has its own16 // a method has several shapes that behave differently, each has its own
17 // constant: GetTree, GetTreeLastCommits (root), GetTreeSubdirLastCommits,17 // constant: GetTree, GetTreeLastCommits (root), GetTreeSubdirLastCommits,
18 // ResolveRevisionAbbrev and ListCommitsPath. A method that does not win stays18 // 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.
20 //21 //
21 // Warm is a repository that is open; cold closes it before every call, the22 // Warm is a repository that is open; cold closes it before every call, the
22 // price of the first read after the idle TTL, an eviction or a restart (a23 // price of the first read after the idle TTL, an eviction or a restart (a
@@ -71,6 +72,10 @@ package ermitegit
71 // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win72 // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win
72 // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win73 // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win
73 // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win74 // 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
74 // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win79 // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win
75 // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba80 // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba
76 // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba81 // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba
@@ -126,6 +131,11 @@ package ermitegit
126 // - OpenBlob by size: below 1 MiB the small blob loses warm bytes and131 // - OpenBlob by size: below 1 MiB the small blob loses warm bytes and
127 // allocations on lazygit; above, it loses time (inflate speed: 52 ms132 // allocations on lazygit; above, it loses time (inflate speed: 52 ms
128 // against 21 ms). No size makes it win, so there is no size-based route.133 // 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.
129 func DefaultRoutes() Routes {139 func DefaultRoutes() Routes {
130 return Routes{140 return Routes{
131 ListRefs: ErmiteGit,141 ListRefs: ErmiteGit,
@@ -138,5 +148,6 @@ func DefaultRoutes() Routes {
138 OpenBlob: CLI,148 OpenBlob: CLI,
139 ListCommits: ErmiteGit,149 ListCommits: ErmiteGit,
140 ListCommitsPath: CLI,150 ListCommitsPath: CLI,
151+ ListPaths: ErmiteGit,
141 }152 }
142 }153 }
M internal/adapters/gitcli/read.go +48 -0
@@ -288,6 +288,54 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path
288 return obj, err288 return obj, err
289 }289 }
290 290
291+// ListPaths lists the files and symbolic links of a revision with
292+// `git ls-tree -r -z`, in its order, up to the bounds of the port.
293+//
294+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
295+func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
296+ dir, err := g.repoDir(repo)
297+ if err != nil {
298+ return domain.PathList{}, err
299+ }
300+ obj, err := g.lookup(ctx, dir, rev, domain.TreePath{})
301+ if err != nil {
302+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
303+ }
304+ treeID, err := domain.ParseObjectID(obj.info.ID)
305+ if err != nil {
306+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
307+ }
308+ list := domain.PathList{CommitID: obj.commit, TreeID: treeID}
309+ err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error {
310+ for {
311+ tok, err := readToken(r)
312+ if errors.Is(err, io.EOF) {
313+ return nil
314+ }
315+ if err != nil {
316+ return err
317+ }
318+ meta, path, ok := strings.Cut(tok, "\t")
319+ if !ok {
320+ return fmt.Errorf("malformed ls-tree entry %q", tok)
321+ }
322+ // meta is "<mode> <type> <id>": a submodule is a commit.
323+ _, rest, _ := strings.Cut(meta, " ")
324+ if typ, _, _ := strings.Cut(rest, " "); typ == "commit" {
325+ continue
326+ }
327+ // A copy: path would keep the whole "<mode> <type> <id>\t" entry alive.
328+ if !list.Add(strings.Clone(path), maxPaths, maxBytes) {
329+ return errStop
330+ }
331+ }
332+ })
333+ if err != nil {
334+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
335+ }
336+ return list, nil
337+}
338+
291 // GetTree lists a directory with `git ls-tree -z -l`.339 // GetTree lists a directory with `git ls-tree -z -l`.
292 //340 //
293 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port341 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
M internal/adapters/rpc/api.go +1 -1
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
26 26
27 // Features are the optional capabilities of the contract that this server27 // Features are the optional capabilities of the contract that this server
28 // implements, as GetVersion lists them.28 // 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"}
30 30
31 // Config configures the API.31 // Config configures the API.
32 type Config struct {32 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
85 return connect.NewResponse(out), nil85 return connect.NewResponse(out), nil
86 }86 }
87 87
88+// ListPaths lists the paths of every file of a revision.
89+func (s *gitService) ListPaths(ctx context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) {
90+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
91+ if err != nil {
92+ return nil, connectError(ctx, err)
93+ }
94+ list, err := s.browse.ListPaths(ctx, caller(ctx), name, rev)
95+ if err != nil {
96+ return nil, connectError(ctx, err)
97+ }
98+ return connect.NewResponse(&pb.ListPathsResponse{
99+ CommitId: list.CommitID.String(),
100+ TreeId: list.TreeID.String(),
101+ Paths: list.Paths,
102+ Truncated: list.Truncated,
103+ }), nil
104+}
105+
88 // GetBlob streams the blob in chunks of BlobChunkSize. The first message106 // GetBlob streams the blob in chunks of BlobChunkSize. The first message
89 // carries the metadata and the first chunk.107 // carries the metadata and the first chunk.
90 func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {108 func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {
M internal/adapters/rpc/git_test.go +51 -0
@@ -3,6 +3,7 @@
3 package rpc3 package rpc
4 4
5 import (5 import (
6+ "strings"
6 "testing"7 "testing"
7 8
8 "connectrpc.com/connect"9 "connectrpc.com/connect"
@@ -50,6 +51,56 @@ func TestGitServiceGetTree(t *testing.T) {
50 }51 }
51 }52 }
52 53
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+
53 func TestGitServiceListRefs(t *testing.T) {104 func TestGitServiceListRefs(t *testing.T) {
54 t.Parallel()105 t.Parallel()
55 tests := []struct {106 tests := []struct {
M internal/adapters/web/ASSETS.md +1 -0
@@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime.
9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11 | `static/js/ermite.js` | key sequences to `k-*` events, 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 | |11 | `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 | |
12 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |13 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
13 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |14 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
14 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |15 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/assets.go +2 -1
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
23 // Assets are the URLs of the assets a page loads. Their names carry a hash of23 // Assets are the URLs of the assets a page loads. Their names carry a hash of
24 // the content, so they can be cached for ever.24 // the content, so they can be cached for ever.
25 type Assets struct {25 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Icon string26+ CSS, HTMX, Live, SSE, App, Files, Icon string
27 // Font is the UI font, preloaded by every page.27 // Font is the UI font, preloaded by every page.
28 Font string28 Font string
29 // Rules is the speculation rules file named by the Speculation-Rules header.29 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -55,6 +55,7 @@ var scripts = []struct {
55 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},55 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
57 {"ermite.js", func(a *Assets, u string) { a.App = u }},57 {"ermite.js", func(a *Assets, u string) { a.App = u }},
58+ {"files.js", func(a *Assets, u string) { a.Files = u }},
58 }59 }
59 60
60 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):61 // 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
186 }186 }
187 187
188 l := s.repoLayout(ctx, d, "tree")188 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)
189 pinRef(&l, t)192 pinRef(&l, t)
190 l.Crumbs = pathCrumbs(d.name(), "blob", t)193 l.Crumbs = pathCrumbs(d.name(), "blob", t)
191 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance194 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
69 }69 }
70 return s.cfg.Browse.OpenBlob(ctx, principal(ctx), repo, r, p)70 return s.cfg.Browse.OpenBlob(ctx, principal(ctx), repo, r, p)
71 }71 }
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() {
17 k-esc from:body -> ermite.closeDialogs();17 k-esc from:body -> ermite.closeDialogs();
18 k-help from:body -> ermite.dialog('panel-help');18 k-help from:body -> ermite.dialog('panel-help');
19 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');19 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();
21 k-filter from:body -> ermite.focusFilter();21 k-filter from:body -> ermite.focusFilter();
22 k-gh from:body -> ermite.go('.brand');22 k-gh from:body -> ermite.go('.brand');
23 k-s from:body -> ermite.tab('s');23 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() {
14 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>14 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>
15 <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>15 <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>
16 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>16 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
17- <div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div>17+ <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div>
18 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>18 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
19 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>19 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
20 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>20 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
@@ -84,9 +84,9 @@ templ refItem(r RefItem) {
84 }84 }
85 85
86 // OverlayCmd is the ":" command line: one filterable list mixing sections,86 // OverlayCmd is the ":" command line: one filterable list mixing sections,
87-// refs, files of this directory and repos.87+// refs and repos.
88 templ OverlayCmd(items []CmdItem) {88 templ OverlayCmd(items []CmdItem) {
89- <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">89+ <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
90 <div class="panel-head">90 <div class="panel-head">
91 <label for="cmd-q" class="sr">go to</label>91 <label for="cmd-q" class="sr">go to</label>
92 <input92 <input
@@ -96,7 +96,7 @@ templ OverlayCmd(items []CmdItem) {
96 autocomplete="off"96 autocomplete="off"
97 spellcheck="false"97 spellcheck="false"
98 aria-controls="cmd-list"98 aria-controls="cmd-list"
99- hx-on:input="ermite.cmdFilter(this)"99+ hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
100 hx-on:keydown="ermite.cmdKey(event)"100 hx-on:keydown="ermite.cmdKey(event)"
101 />101 />
102 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>102 <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) {
77 // id of the blob at a path: a tree listing of its directory costs more77 // id of the blob at a path: a tree listing of its directory costs more
78 // than opening a small file.78 // than opening a small file.
79 cacheHeaders(ctx, h, "no-cache", sessionCache)79 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) {
81 w.WriteHeader(http.StatusNotModified)81 w.WriteHeader(http.StatusNotModified)
82 return82 return
83 }83 }
M internal/adapters/web/raw_test.go +4 -1
@@ -61,7 +61,10 @@ func TestRawRevalidation(t *testing.T) {
61 {"no validator", "/r/raw/main/docs/a.txt", http.MethodGet, "", http.StatusOK, "hello"},61 {"no validator", "/r/raw/main/docs/a.txt", http.MethodGet, "", http.StatusOK, "hello"},
62 {"matching validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotModified, ""},62 {"matching validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotModified, ""},
63 {"other validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"ffffffffffffffffffffffffffffffffffffffff"`, http.StatusOK, "hello"},63 {"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"},
65 {"head", "/r/raw/main/docs/a.txt", http.MethodHead, "", http.StatusOK, ""},68 {"head", "/r/raw/main/docs/a.txt", http.MethodHead, "", http.StatusOK, ""},
66 {"missing file", "/r/raw/main/docs/none.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},69 {"missing file", "/r/raw/main/docs/none.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},
67 {"directory", "/r/raw/main", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},70 {"directory", "/r/raw/main", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},
M internal/adapters/web/refs.go +45 -1
@@ -3,6 +3,7 @@
3 package web3 package web
4 4
5 import (5 import (
6+ "context"
6 "net/url"7 "net/url"
7 "strings"8 "strings"
8 9
@@ -79,6 +80,34 @@ type target struct {
79 path string80 path string
80 // detached is true when the URL names a commit and not a ref.81 // detached is true when the URL names a commit and not a ref.
81 detached bool82 detached bool
83+ // commit is the full id of the commit when the page has learnt it from
84+ // the API and rev is an abbreviation; "" otherwise.
85+ commit string
86+}
87+
88+// commitID is the full id of the commit of the target when it is known, and
89+// rev otherwise.
90+func (t target) commitID() string {
91+ if t.commit != "" {
92+ return t.commit
93+ }
94+ return t.rev
95+}
96+
97+// resolveCommit sets the full id of a target named by an abbreviated commit id,
98+// with one ResolveRevision. The page renders without it (it only lacks the file
99+// finder), so a failure is not one.
100+//
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()
82 }111 }
83 112
84 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the113 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
@@ -134,13 +163,14 @@ func (d *repoData) isBranchHead(t target) bool {
134 163
135 // pinRef points the ref switcher of the layout at what a page shows: a164 // pinRef points the ref switcher of the layout at what a page shows: a
136 // branch or a tag, which is then the current one of the lists, or a commit,165 // 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.
138 func pinRef(l *Layout, t target) {167 func pinRef(l *Layout, t target) {
139 if t.detached {168 if t.detached {
140 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""169 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
141 if len(t.rev) == 40 {170 if len(t.rev) == 40 {
142 l.SHA = t.rev171 l.SHA = t.rev
143 }172 }
173+ l.setFinder(t.commitID(), t.rev)
144 for i := range l.Heads {174 for i := range l.Heads {
145 l.Heads[i].Current = false175 l.Heads[i].Current = false
146 }176 }
@@ -150,6 +180,7 @@ func pinRef(l *Layout, t target) {
150 return180 return
151 }181 }
152 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)182 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
183+ l.setFinder(t.rev, t.name)
153 for i := range l.Heads {184 for i := range l.Heads {
154 l.Heads[i].Current = l.Heads[i].Name == t.name185 l.Heads[i].Current = l.Heads[i].Name == t.name
155 }186 }
@@ -158,6 +189,19 @@ func pinRef(l *Layout, t target) {
158 }189 }
159 }190 }
160 191
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+
161 func hasName(items []RefItem, name string) bool {205 func hasName(items []RefItem, name string) bool {
162 for _, it := range items {206 for _, it := range items {
163 if it.Name == name {207 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
121 def = d.head.ShortName121 def = d.head.ShortName
122 l.Ref, l.RefPath = def, def122 l.Ref, l.RefPath = def, def
123 l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String())123 l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String())
124+ l.setFinder(l.SHA, def)
124 }125 }
125 mk := func(key, label, href, id string) Section {126 mk := func(key, label, href, id string) Section {
126 return Section{Key: key, Label: label, Href: href, Current: section == id}127 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) {
21 <script src={ l.Assets.Live } defer></script>21 <script src={ l.Assets.Live } defer></script>
22 <script src={ l.Assets.SSE } defer></script>22 <script src={ l.Assets.SSE } defer></script>
23 <script src={ l.Assets.App } defer></script>23 <script src={ l.Assets.App } defer></script>
24+ <script src={ l.Assets.Files } defer></script>
24 </head>25 </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+ >
26 <a class="skip" href="#main">skip to content</a>36 <a class="skip" href="#main">skip to content</a>
27 @Header(l)37 @Header(l)
28 <div class={ "work", templ.KV("has-rail", l.HasRail && rail != nil) }>38 <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 @@
54 & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }54 & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }
55 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }55 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
56 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }56 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }
57+ & .dir { color: var(--ink-dim); }
58+ & strong { color: var(--ink-hi); font-weight: 700; }
59+ & mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; }
57 }60 }
58 61
59 .keys {62 .keys {
M internal/adapters/web/static/css/30-list.css +10 -1
@@ -38,7 +38,10 @@
38 }38 }
39 .list.tree {39 .list.tree {
40 & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }40 & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }
41- & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); }41+ /* --depth is set by files.js on the rows opened in place */
42+ & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); padding-inline-start: calc(var(--s3) + var(--depth, 0) * 1.25ch); }
43+ & tr[aria-busy=true] .c-kind { opacity: .4; }
44+ & .tree-more td { padding-inline-start: calc(var(--s3) + 36px + var(--depth, 0) * 1.25ch); color: var(--ink-dim); font-size: var(--fs-s); }
42 & .c-size { width: 90px; text-align: right; }45 & .c-size { width: 90px; text-align: right; }
43 & .c-when { width: 100px; }46 & .c-when { width: 100px; }
44 & .c-sub { width: auto; }47 & .c-sub { width: auto; }
@@ -71,6 +74,12 @@
71 &:focus-within::after { display: none; }74 &:focus-within::after { display: none; }
72 }75 }
73 76
77+ /* file finder: the results replace the tree while the field has a query */
78+ #finder {
79+ margin-top: var(--s3);
80+ & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }
81+ }
82+
74 /* section headings inside a page */83 /* section headings inside a page */
75 .rule-h {84 .rule-h {
76 display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5);85 display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5);
M internal/adapters/web/static/js/ermite.js +9 -3
@@ -110,7 +110,11 @@
110 // moving the cursor, and returns the rows.110 // moving the cursor, and returns the rows.
111 hide(input) {111 hide(input) {
112 const t = input.value.toLowerCase();112 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);
114 for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);118 for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
115 if (input.dataset.groups) {119 if (input.dataset.groups) {
116 for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));120 for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
@@ -120,10 +124,12 @@
120 // The "go to" palette.124 // The "go to" palette.
121 cmdFilter(input) {125 cmdFilter(input) {
122 const t = input.value.toLowerCase().trim();126 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]');
124 for (const li of items) li.hidden = !li.dataset.k.includes(t);130 for (const li of items) li.hidden = !li.dataset.k.includes(t);
125 const first = items.find((li) => !li.hidden);131 const first = items.find((li) => !li.hidden);
126- if (first) setCursor(first, items);132+ if (first) setCursor(first, $$('#cmd-list > li'));
127 },133 },
128 cmdKey(e) {134 cmdKey(e) {
129 const vis = $$('#cmd-list > li:not([hidden])');135 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 (
11 "bnema.dev/ermite/internal/domain"11 "bnema.dev/ermite/internal/domain"
12 )12 )
13 13
14-// maxFileTargets bounds the files of a directory offered in the ":" list.
15-const maxFileTargets = 200
16-
17 // TreeRow is one entry of a directory.14 // TreeRow is one entry of a directory.
18 type TreeRow struct {15 type TreeRow struct {
19- Glyph string // ▾ directory, @ symbolic link, + submodule, · file16+ Glyph string // ▸ directory, @ symbolic link, + submodule, · file
20- Name string17+ Name string
21- Href string // "" for a submodule18+ 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
22 Size string22 Size string
23 Subject string23 Subject string
24 CHref string24 CHref string
@@ -34,6 +34,9 @@ type TreePage struct {
34 Readme *Readme34 Readme *Readme
35 // Notes say what the listing leaves out.35 // Notes say what the listing leaves out.
36 Notes []string36 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
37 }40 }
38 41
39 // tree lists a directory of a ref, with the last commit of each entry, and42 // 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
56 entries := res.Entries59 entries := res.Entries
57 now := s.cfg.Now()60 now := s.cfg.Now()
58 61
62+ rowsOnly := r.URL.Query().Has("rows")
59 p := TreePage{}63 p := TreePage{}
60- l := s.repoLayout(ctx, d, "tree")
61 for i, e := range entries {64 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}
63 switch e.Kind {66 switch e.Kind {
64 case domain.EntryDirectory:67 case domain.EntryDirectory:
65- row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"68+ row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-"
66 row.Href = repoHref(d.name(), "tree", t.name, e.Path)69 row.Href = repoHref(d.name(), "tree", t.name, e.Path)
70+ row.Expand = row.Href + "?rows"
67 case domain.EntrySymlink:71 case domain.EntrySymlink:
68 row.Glyph = "@"72 row.Glyph = "@"
69 row.Href = repoHref(d.name(), "blob", t.name, e.Path)73 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
71 row.Glyph, row.Size = "+", "-"75 row.Glyph, row.Size = "+", "-"
72 default:76 default:
73 row.Href = repoHref(d.name(), "blob", t.name, e.Path)77 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- }
77 }78 }
78 if c := e.LastCommit; c != nil {79 if c := e.LastCommit; c != nil {
79 last := commitRow(d.name(), *c, now)80 last := commitRow(d.name(), *c, now)
@@ -81,6 +82,13 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
81 }82 }
82 p.Rows = append(p.Rows, row)83 p.Rows = append(p.Rows, row)
83 }84 }
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+ }
84 if res.Truncated {92 if res.Truncated {
85 p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")93 p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")
86 }94 }
@@ -95,6 +103,10 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
95 p.Readme = rd.readme103 p.Readme = rd.readme
96 }104 }
97 105
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()
98 pinRef(&l, t)110 pinRef(&l, t)
99 l.Crumbs = pathCrumbs(d.name(), "tree", t)111 l.Crumbs = pathCrumbs(d.name(), "tree", t)
100 if t.path == "" {112 if t.path == "" {
M internal/adapters/web/tree.templ +58 -28
@@ -2,52 +2,82 @@
2 2
3 package web3 package web
4 4
5-// Tree is a directory: the filter, the entries with their last commit, the5+// Tree is a directory: the file finder, the entries with their last commit, the
6 // notes on what is left out, and the README of the directory below.6 // notes on what is left out, and the README of the directory below.
7 templ Tree(l Layout, p TreePage) {7 templ Tree(l Layout, p TreePage) {
8 @Shell(l, nil) {8 @Shell(l, nil) {
9 <div class="filter">9 <div class="filter">
10- <label for="tree-filter" class="sr">filter</label>10+ <label for="finder-q" class="sr">find a file</label>
11 <input11 <input
12- id="tree-filter"12+ id="finder-q"
13 type="search"13 type="search"
14- data-filter="#tree-rows > tr"14+ data-filter
15- placeholder="Filter this directory…"15+ role="combobox"
16+ aria-autocomplete="list"
17+ aria-controls="finder-list"
18+ aria-expanded="false"
19+ placeholder="Find a file…"
16 autocomplete="off"20 autocomplete="off"
17- hx-on:input="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)"
18 />25 />
19 </div>26 </div>
20- <table class="list tree" aria-label="Entries">27+ <div id="finder" hidden>
21- <thead>28+ <p id="finder-status" class="dim" aria-live="polite"></p>
22- <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>29+ <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
23- </thead>30+ </div>
24- <tbody id="tree-rows" data-rows>31+ <div id="tree-view">
25- for _, r := range p.Rows {32+ <table class="list tree" aria-label="Entries">
26- @TreeRowView(r)33+ <thead>
27- }34+ <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>
28- </tbody>35+ </thead>
29- </table>36+ <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
30- if len(p.Rows) == 0 {37+ @TreeRows(p)
31- <p class="lede dim">this directory is empty.</p>38+ </tbody>
32- }39+ </table>
33- for _, n := range p.Notes {40+ if len(p.Rows) == 0 {
34- <p class="note">{ n }</p>41+ <p class="lede dim">this directory is empty.</p>
35- }42+ }
36- if p.Readme != nil {43+ for _, n := range p.Notes {
37- @ReadmeCard(*p.Readme)44+ <p class="note">{ n }</p>
38- }45+ }
46+ if p.Readme != nil {
47+ @ReadmeCard(*p.Readme)
48+ }
49+ </div>
50+ }
51+}
52+
53+// TreeRows is the rows of a directory alone: what files.js inserts under a
54+// directory it opens in place. When the directory has more entries than are
55+// listed, the last row links to its page.
56+templ TreeRows(p TreePage) {
57+ for _, r := range p.Rows {
58+ @TreeRowView(r)
59+ }
60+ if p.More != "" {
61+ <tr class="tree-more"><td colspan="7"><a href={ templ.SafeURL(p.More) }>more entries: open the directory</a></td></tr>
39 }62 }
40 }63 }
41 64
42-// TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic65+// TreeRowView is one entry of a directory. Glyph: ▸ directory (▾ once opened
43-// link, + submodule, · file.66+// in place), @ symbolic link, + submodule, · file. A directory link carries
67+// data-expand, the URL of its rows.
44 templ TreeRowView(r TreeRow) {68 templ TreeRowView(r TreeRow) {
45 <tr data-cursor?={ r.Cursor }>69 <tr data-cursor?={ r.Cursor }>
46 <td class="cur" aria-hidden="true"></td>70 <td class="cur" aria-hidden="true"></td>
47 <td class="c-kind" aria-hidden="true">{ r.Glyph }</td>71 <td class="c-kind" aria-hidden="true">{ r.Glyph }</td>
48 <td class="c-name">72 <td class="c-name">
49 if r.Href != "" {73 if r.Href != "" {
50- <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>74+ <a
75+ href={ templ.SafeURL(r.Href) }
76+ if r.Expand != "" {
77+ data-expand={ r.Expand }
78+ aria-expanded="false"
79+ }
80+ >{ r.Name }</a>
51 } else {81 } else {
52 { r.Name }82 { r.Name }
53 }83 }
M internal/adapters/web/tree_templ.go +153 -78 generated file: collapsed
M internal/adapters/web/view.go +4 -0
@@ -56,6 +56,10 @@ type Layout struct {
56 Live bool56 Live bool
57 // LiveURL is the stream the page follows, with its query.57 // LiveURL is the stream the page follows, with its query.
58 LiveURL string58 LiveURL string
59+ // PathsURL is the list of the file paths of the commit the page shows
60+ // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
61+ // both are "" when the page has no commit to search.
62+ PathsURL, BlobBase string
59 }63 }
60 64
61 // Link is a plain label + href pair.65 // Link is a plain label + href pair.
M internal/adapters/web/web.go +6 -2
@@ -88,7 +88,9 @@ type Server struct {
88 origin string // scheme://host of the public URL, what a POST must come from88 origin string // scheme://host of the public URL, what a POST must come from
89 assets *assetSet89 assets *assetSet
90 md *markdown90 md *markdown
91- mux *http.ServeMux91+ // pathCache holds the path lists the file finder fetches.
92+ pathCache *pathCache
93+ mux *http.ServeMux
92 // refsFan shares the fragments of the refs page between the watchers of94 // refsFan shares the fragments of the refs page between the watchers of
93 // a push; entries are few and short-lived.95 // a push; entries are few and short-lived.
94 refsFan fanout96 refsFan fanout
@@ -115,7 +117,8 @@ func New(cfg Config) (*Server, error) {
115 }117 }
116 s := &Server{118 s := &Server{
117 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),119 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
118- origin: originOf(u),120+ pathCache: newPathCache(pathCacheBytes, pathBuilds),
121+ origin: originOf(u),
119 }122 }
120 s.mux = s.routes()123 s.mux = s.routes()
121 return s, nil124 return s, nil
@@ -143,6 +146,7 @@ func (s *Server) routes() *http.ServeMux {
143 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))146 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
144 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))147 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
145 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)148 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
149+ mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
146 if s.cfg.MergeRequests != nil {150 if s.cfg.MergeRequests != nil {
147 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))151 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
148 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))152 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 {
68 // every principal in a stable order.68 // every principal in a stable order.
69 var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad}69 var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad}
70 70
71+// TestListPathsRPC: the owner lists public and private repositories, an
72+// anonymous caller sees the private one as missing, and so is a bad revision.
73+func TestListPathsRPC(t *testing.T) {
74+ r := newAccessRig(t)
75+ r.seed("pub", domain.VisibilityPublic, "README.md", "docs/a.txt")
76+ r.seed("priv", domain.VisibilityPrivate, "README.md")
77+ ctx := r.ctx
78+ root, anon := r.who[pRoot], r.who[pAnon]
79+
80+ list := func(c *clients, repo, rev string) (*pb.ListPathsResponse, error) {
81+ resp, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: rev}))
82+ if err != nil {
83+ return nil, err
84+ }
85+ return resp.Msg, nil
86+ }
87+
88+ pub, err := list(root, "pub", "")
89+ require.NoError(t, err)
90+ assert.Equal(t, []string{"README.md", "docs/a.txt"}, pub.GetPaths())
91+ assert.NotEmpty(t, pub.GetCommitId())
92+ assert.NotEmpty(t, pub.GetTreeId())
93+ assert.False(t, pub.GetTruncated())
94+
95+ priv, err := list(root, "priv", "")
96+ require.NoError(t, err)
97+ assert.Equal(t, []string{"README.md"}, priv.GetPaths())
98+
99+ _, err = list(anon, "priv", "")
100+ assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
101+ _, err = list(root, "pub", "nope")
102+ assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
103+}
104+
71 // TestAC2ReadAccess: every read RPC on a public repository, a private one and105 // TestAC2ReadAccess: every read RPC on a public repository, a private one and
72 // one inside the limited token's list.106 // one inside the limited token's list.
73 func TestAC2ReadAccess(t *testing.T) {107 func TestAC2ReadAccess(t *testing.T) {
@@ -90,6 +124,10 @@ func TestAC2ReadAccess(t *testing.T) {
90 _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo}))124 _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo}))
91 return err125 return err
92 },126 },
127+ "ListPaths": func(c *clients, repo string) error {
128+ _, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo}))
129+ return err
130+ },
93 "GetBlob": func(c *clients, repo string) error {131 "GetBlob": func(c *clients, repo string) error {
94 _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"})132 _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"})
95 return err133 return err
@@ -158,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
158 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160 require.NoError(t, err, who)198 require.NoError(t, err, who)
161- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who)
162 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
163 }201 }
164 })202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324 ver := decode[versionOut](t, res.stdout)324 ver := decode[versionOut](t, res.stdout)
325 assert.Equal(t, c.url, ver.Server.URL)325 assert.Equal(t, c.url, ver.Server.URL)
326 assert.NotEmpty(t, ver.Server.Version)326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, ver.Server.Features)327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)
328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329 stdout, stderr, code = c.bin(t, secret, "version")329 stdout, stderr, code = c.bin(t, secret, "version")
330 require.Equal(t, 0, code, stderr)330 require.Equal(t, 0, code, stderr)
M internal/app/testdata/web/blob-binary.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -174,7 +176,7 @@
174 <dt>176 <dt>
175 <kbd>/</kbd>177 <kbd>/</kbd>
176 </dt>178 </dt>
177-<dd>filter the list</dd>179+<dd>filter the list; on Files, find a file in the repository</dd>
178 </div>180 </div>
179 <div>181 <div>
180 <dt>182 <dt>
@@ -261,10 +263,10 @@
261 </section>263 </section>
262 </div>264 </div>
263 </dialog>265 </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()">
265 <div class="panel-head">267 <div class="panel-head">
266 <label for="cmd-q" class="sr">go to</label>268 <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)">
268 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
269 <kbd>esc</kbd>271 <kbd>esc</kbd>
270 </button>272 </button>
@@ -343,7 +345,7 @@
343 k-esc from:body -> ermite.closeDialogs();345 k-esc from:body -> ermite.closeDialogs();
344 k-help from:body -> ermite.dialog('panel-help');346 k-help from:body -> ermite.dialog('panel-help');
345 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');347 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();
347 k-filter from:body -> ermite.focusFilter();349 k-filter from:body -> ermite.focusFilter();
348 k-gh from:body -> ermite.go('.brand');350 k-gh from:body -> ermite.go('.brand');
349 k-s from:body -> ermite.tab('s');351 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-code.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -244,7 +246,7 @@
244 <dt>246 <dt>
245 <kbd>/</kbd>247 <kbd>/</kbd>
246 </dt>248 </dt>
247-<dd>filter the list</dd>249+<dd>filter the list; on Files, find a file in the repository</dd>
248 </div>250 </div>
249 <div>251 <div>
250 <dt>252 <dt>
@@ -331,10 +333,10 @@
331 </section>333 </section>
332 </div>334 </div>
333 </dialog>335 </dialog>
334-<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">336+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
335 <div class="panel-head">337 <div class="panel-head">
336 <label for="cmd-q" class="sr">go to</label>338 <label for="cmd-q" class="sr">go to</label>
337-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="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)">
338 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">340 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
339 <kbd>esc</kbd>341 <kbd>esc</kbd>
340 </button>342 </button>
@@ -413,7 +415,7 @@
413 k-esc from:body -> ermite.closeDialogs();415 k-esc from:body -> ermite.closeDialogs();
414 k-help from:body -> ermite.dialog('panel-help');416 k-help from:body -> ermite.dialog('panel-help');
415 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');417 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();
417 k-filter from:body -> ermite.focusFilter();419 k-filter from:body -> ermite.focusFilter();
418 k-gh from:body -> ermite.go('.brand');420 k-gh from:body -> ermite.go('.brand');
419 k-s from:body -> ermite.tab('s');421 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-empty.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -174,7 +176,7 @@
174 <dt>176 <dt>
175 <kbd>/</kbd>177 <kbd>/</kbd>
176 </dt>178 </dt>
177-<dd>filter the list</dd>179+<dd>filter the list; on Files, find a file in the repository</dd>
178 </div>180 </div>
179 <div>181 <div>
180 <dt>182 <dt>
@@ -261,10 +263,10 @@
261 </section>263 </section>
262 </div>264 </div>
263 </dialog>265 </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()">
265 <div class="panel-head">267 <div class="panel-head">
266 <label for="cmd-q" class="sr">go to</label>268 <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)">
268 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
269 <kbd>esc</kbd>271 <kbd>esc</kbd>
270 </button>272 </button>
@@ -343,7 +345,7 @@
343 k-esc from:body -> ermite.closeDialogs();345 k-esc from:body -> ermite.closeDialogs();
344 k-help from:body -> ermite.dialog('panel-help');346 k-help from:body -> ermite.dialog('panel-help');
345 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');347 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();
347 k-filter from:body -> ermite.focusFilter();349 k-filter from:body -> ermite.focusFilter();
348 k-gh from:body -> ermite.go('.brand');350 k-gh from:body -> ermite.go('.brand');
349 k-s from:body -> ermite.tab('s');351 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-image.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -176,7 +178,7 @@
176 <dt>178 <dt>
177 <kbd>/</kbd>179 <kbd>/</kbd>
178 </dt>180 </dt>
179-<dd>filter the list</dd>181+<dd>filter the list; on Files, find a file in the repository</dd>
180 </div>182 </div>
181 <div>183 <div>
182 <dt>184 <dt>
@@ -263,10 +265,10 @@
263 </section>265 </section>
264 </div>266 </div>
265 </dialog>267 </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()">
267 <div class="panel-head">269 <div class="panel-head">
268 <label for="cmd-q" class="sr">go to</label>270 <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)">
270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">272 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
271 <kbd>esc</kbd>273 <kbd>esc</kbd>
272 </button>274 </button>
@@ -345,7 +347,7 @@
345 k-esc from:body -> ermite.closeDialogs();347 k-esc from:body -> ermite.closeDialogs();
346 k-help from:body -> ermite.dialog('panel-help');348 k-help from:body -> ermite.dialog('panel-help');
347 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');349 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();
349 k-filter from:body -> ermite.focusFilter();351 k-filter from:body -> ermite.focusFilter();
350 k-gh from:body -> ermite.go('.brand');352 k-gh from:body -> ermite.go('.brand');
351 k-s from:body -> ermite.tab('s');353 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-markdown.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -241,7 +243,7 @@
241 <dt>243 <dt>
242 <kbd>/</kbd>244 <kbd>/</kbd>
243 </dt>245 </dt>
244-<dd>filter the list</dd>246+<dd>filter the list; on Files, find a file in the repository</dd>
245 </div>247 </div>
246 <div>248 <div>
247 <dt>249 <dt>
@@ -328,10 +330,10 @@
328 </section>330 </section>
329 </div>331 </div>
330 </dialog>332 </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()">
332 <div class="panel-head">334 <div class="panel-head">
333 <label for="cmd-q" class="sr">go to</label>335 <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)">
335 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">337 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
336 <kbd>esc</kbd>338 <kbd>esc</kbd>
337 </button>339 </button>
@@ -410,7 +412,7 @@
410 k-esc from:body -> ermite.closeDialogs();412 k-esc from:body -> ermite.closeDialogs();
411 k-help from:body -> ermite.dialog('panel-help');413 k-help from:body -> ermite.dialog('panel-help');
412 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');414 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();
414 k-filter from:body -> ermite.focusFilter();416 k-filter from:body -> ermite.focusFilter();
415 k-gh from:body -> ermite.go('.brand');417 k-gh from:body -> ermite.go('.brand');
416 k-s from:body -> ermite.tab('s');418 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-svg.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -199,7 +201,7 @@
199 <dt>201 <dt>
200 <kbd>/</kbd>202 <kbd>/</kbd>
201 </dt>203 </dt>
202-<dd>filter the list</dd>204+<dd>filter the list; on Files, find a file in the repository</dd>
203 </div>205 </div>
204 <div>206 <div>
205 <dt>207 <dt>
@@ -286,10 +288,10 @@
286 </section>288 </section>
287 </div>289 </div>
288 </dialog>290 </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()">
290 <div class="panel-head">292 <div class="panel-head">
291 <label for="cmd-q" class="sr">go to</label>293 <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)">
293 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">295 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
294 <kbd>esc</kbd>296 <kbd>esc</kbd>
295 </button>297 </button>
@@ -368,7 +370,7 @@
368 k-esc from:body -> ermite.closeDialogs();370 k-esc from:body -> ermite.closeDialogs();
369 k-help from:body -> ermite.dialog('panel-help');371 k-help from:body -> ermite.dialog('panel-help');
370 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');372 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();
372 k-filter from:body -> ermite.focusFilter();374 k-filter from:body -> ermite.focusFilter();
373 k-gh from:body -> ermite.go('.brand');375 k-gh from:body -> ermite.go('.brand');
374 k-s from:body -> ermite.tab('s');376 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-text.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -194,7 +196,7 @@
194 <dt>196 <dt>
195 <kbd>/</kbd>197 <kbd>/</kbd>
196 </dt>198 </dt>
197-<dd>filter the list</dd>199+<dd>filter the list; on Files, find a file in the repository</dd>
198 </div>200 </div>
199 <div>201 <div>
200 <dt>202 <dt>
@@ -281,10 +283,10 @@
281 </section>283 </section>
282 </div>284 </div>
283 </dialog>285 </dialog>
284-<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">286+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
285 <div class="panel-head">287 <div class="panel-head">
286 <label for="cmd-q" class="sr">go to</label>288 <label for="cmd-q" class="sr">go to</label>
287-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="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)">
288 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">290 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
289 <kbd>esc</kbd>291 <kbd>esc</kbd>
290 </button>292 </button>
@@ -363,7 +365,7 @@
363 k-esc from:body -> ermite.closeDialogs();365 k-esc from:body -> ermite.closeDialogs();
364 k-help from:body -> ermite.dialog('panel-help');366 k-help from:body -> ermite.dialog('panel-help');
365 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');367 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();
367 k-filter from:body -> ermite.focusFilter();369 k-filter from:body -> ermite.focusFilter();
368 k-gh from:body -> ermite.go('.brand');370 k-gh from:body -> ermite.go('.brand');
369 k-s from:body -> ermite.tab('s');371 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-too-large.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -174,7 +176,7 @@
174 <dt>176 <dt>
175 <kbd>/</kbd>177 <kbd>/</kbd>
176 </dt>178 </dt>
177-<dd>filter the list</dd>179+<dd>filter the list; on Files, find a file in the repository</dd>
178 </div>180 </div>
179 <div>181 <div>
180 <dt>182 <dt>
@@ -261,10 +263,10 @@
261 </section>263 </section>
262 </div>264 </div>
263 </dialog>265 </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()">
265 <div class="panel-head">267 <div class="panel-head">
266 <label for="cmd-q" class="sr">go to</label>268 <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)">
268 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
269 <kbd>esc</kbd>271 <kbd>esc</kbd>
270 </button>272 </button>
@@ -343,7 +345,7 @@
343 k-esc from:body -> ermite.closeDialogs();345 k-esc from:body -> ermite.closeDialogs();
344 k-help from:body -> ermite.dialog('panel-help');346 k-help from:body -> ermite.dialog('panel-help');
345 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');347 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();
347 k-filter from:body -> ermite.focusFilter();349 k-filter from:body -> ermite.focusFilter();
348 k-gh from:body -> ermite.go('.brand');350 k-gh from:body -> ermite.go('.brand');
349 k-s from:body -> ermite.tab('s');351 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/blob-trojan.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -241,7 +243,7 @@
241 <dt>243 <dt>
242 <kbd>/</kbd>244 <kbd>/</kbd>
243 </dt>245 </dt>
244-<dd>filter the list</dd>246+<dd>filter the list; on Files, find a file in the repository</dd>
245 </div>247 </div>
246 <div>248 <div>
247 <dt>249 <dt>
@@ -328,10 +330,10 @@
328 </section>330 </section>
329 </div>331 </div>
330 </dialog>332 </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()">
332 <div class="panel-head">334 <div class="panel-head">
333 <label for="cmd-q" class="sr">go to</label>335 <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)">
335 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">337 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
336 <kbd>esc</kbd>338 <kbd>esc</kbd>
337 </button>339 </button>
@@ -410,7 +412,7 @@
410 k-esc from:body -> ermite.closeDialogs();412 k-esc from:body -> ermite.closeDialogs();
411 k-help from:body -> ermite.dialog('panel-help');413 k-help from:body -> ermite.dialog('panel-help');
412 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');414 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();
414 k-filter from:body -> ermite.focusFilter();416 k-filter from:body -> ermite.focusFilter();
415 k-gh from:body -> ermite.go('.brand');417 k-gh from:body -> ermite.go('.brand');
416 k-s from:body -> ermite.tab('s');418 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-big.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -211,7 +213,7 @@
211 <dt>213 <dt>
212 <kbd>/</kbd>214 <kbd>/</kbd>
213 </dt>215 </dt>
214-<dd>filter the list</dd>216+<dd>filter the list; on Files, find a file in the repository</dd>
215 </div>217 </div>
216 <div>218 <div>
217 <dt>219 <dt>
@@ -298,10 +300,10 @@
298 </section>300 </section>
299 </div>301 </div>
300 </dialog>302 </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()">
302 <div class="panel-head">304 <div class="panel-head">
303 <label for="cmd-q" class="sr">go to</label>305 <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)">
305 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">307 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
306 <kbd>esc</kbd>308 <kbd>esc</kbd>
307 </button>309 </button>
@@ -380,7 +382,7 @@
380 k-esc from:body -> ermite.closeDialogs();382 k-esc from:body -> ermite.closeDialogs();
381 k-help from:body -> ermite.dialog('panel-help');383 k-help from:body -> ermite.dialog('panel-help');
382 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');384 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();
384 k-filter from:body -> ermite.focusFilter();386 k-filter from:body -> ermite.focusFilter();
385 k-gh from:body -> ermite.go('.brand');387 k-gh from:body -> ermite.go('.brand');
386 k-s from:body -> ermite.tab('s');388 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-binary.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -206,7 +208,7 @@
206 <dt>208 <dt>
207 <kbd>/</kbd>209 <kbd>/</kbd>
208 </dt>210 </dt>
209-<dd>filter the list</dd>211+<dd>filter the list; on Files, find a file in the repository</dd>
210 </div>212 </div>
211 <div>213 <div>
212 <dt>214 <dt>
@@ -293,10 +295,10 @@
293 </section>295 </section>
294 </div>296 </div>
295 </dialog>297 </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()">
297 <div class="panel-head">299 <div class="panel-head">
298 <label for="cmd-q" class="sr">go to</label>300 <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)">
300 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
301 <kbd>esc</kbd>303 <kbd>esc</kbd>
302 </button>304 </button>
@@ -375,7 +377,7 @@
375 k-esc from:body -> ermite.closeDialogs();377 k-esc from:body -> ermite.closeDialogs();
376 k-help from:body -> ermite.dialog('panel-help');378 k-help from:body -> ermite.dialog('panel-help');
377 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');379 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();
379 k-filter from:body -> ermite.focusFilter();381 k-filter from:body -> ermite.focusFilter();
380 k-gh from:body -> ermite.go('.brand');382 k-gh from:body -> ermite.go('.brand');
381 k-s from:body -> ermite.tab('s');383 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-merge.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -245,7 +247,7 @@
245 <dt>247 <dt>
246 <kbd>/</kbd>248 <kbd>/</kbd>
247 </dt>249 </dt>
248-<dd>filter the list</dd>250+<dd>filter the list; on Files, find a file in the repository</dd>
249 </div>251 </div>
250 <div>252 <div>
251 <dt>253 <dt>
@@ -332,10 +334,10 @@
332 </section>334 </section>
333 </div>335 </div>
334 </dialog>336 </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()">
336 <div class="panel-head">338 <div class="panel-head">
337 <label for="cmd-q" class="sr">go to</label>339 <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)">
339 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">341 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
340 <kbd>esc</kbd>342 <kbd>esc</kbd>
341 </button>343 </button>
@@ -414,7 +416,7 @@
414 k-esc from:body -> ermite.closeDialogs();416 k-esc from:body -> ermite.closeDialogs();
415 k-help from:body -> ermite.dialog('panel-help');417 k-help from:body -> ermite.dialog('panel-help');
416 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');418 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();
418 k-filter from:body -> ermite.focusFilter();420 k-filter from:body -> ermite.focusFilter();
419 k-gh from:body -> ermite.go('.brand');421 k-gh from:body -> ermite.go('.brand');
420 k-s from:body -> ermite.tab('s');422 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-modify-split.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -366,7 +368,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
366 <dt>368 <dt>
367 <kbd>/</kbd>369 <kbd>/</kbd>
368 </dt>370 </dt>
369-<dd>filter the list</dd>371+<dd>filter the list; on Files, find a file in the repository</dd>
370 </div>372 </div>
371 <div>373 <div>
372 <dt>374 <dt>
@@ -453,10 +455,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
453 </section>455 </section>
454 </div>456 </div>
455 </dialog>457 </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()">
457 <div class="panel-head">459 <div class="panel-head">
458 <label for="cmd-q" class="sr">go to</label>460 <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)">
460 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">462 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
461 <kbd>esc</kbd>463 <kbd>esc</kbd>
462 </button>464 </button>
@@ -535,7 +537,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
535 k-esc from:body -> ermite.closeDialogs();537 k-esc from:body -> ermite.closeDialogs();
536 k-help from:body -> ermite.dialog('panel-help');538 k-help from:body -> ermite.dialog('panel-help');
537 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');539 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();
539 k-filter from:body -> ermite.focusFilter();541 k-filter from:body -> ermite.focusFilter();
540 k-gh from:body -> ermite.go('.brand');542 k-gh from:body -> ermite.go('.brand');
541 k-s from:body -> ermite.tab('s');543 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-modify.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -366,7 +368,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
366 <dt>368 <dt>
367 <kbd>/</kbd>369 <kbd>/</kbd>
368 </dt>370 </dt>
369-<dd>filter the list</dd>371+<dd>filter the list; on Files, find a file in the repository</dd>
370 </div>372 </div>
371 <div>373 <div>
372 <dt>374 <dt>
@@ -453,10 +455,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
453 </section>455 </section>
454 </div>456 </div>
455 </dialog>457 </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()">
457 <div class="panel-head">459 <div class="panel-head">
458 <label for="cmd-q" class="sr">go to</label>460 <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)">
460 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">462 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
461 <kbd>esc</kbd>463 <kbd>esc</kbd>
462 </button>464 </button>
@@ -535,7 +537,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
535 k-esc from:body -> ermite.closeDialogs();537 k-esc from:body -> ermite.closeDialogs();
536 k-help from:body -> ermite.dialog('panel-help');538 k-help from:body -> ermite.dialog('panel-help');
537 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');539 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();
539 k-filter from:body -> ermite.focusFilter();541 k-filter from:body -> ermite.focusFilter();
540 k-gh from:body -> ermite.go('.brand');542 k-gh from:body -> ermite.go('.brand');
541 k-s from:body -> ermite.tab('s');543 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-rename.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -271,7 +273,7 @@
271 <dt>273 <dt>
272 <kbd>/</kbd>274 <kbd>/</kbd>
273 </dt>275 </dt>
274-<dd>filter the list</dd>276+<dd>filter the list; on Files, find a file in the repository</dd>
275 </div>277 </div>
276 <div>278 <div>
277 <dt>279 <dt>
@@ -358,10 +360,10 @@
358 </section>360 </section>
359 </div>361 </div>
360 </dialog>362 </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()">
362 <div class="panel-head">364 <div class="panel-head">
363 <label for="cmd-q" class="sr">go to</label>365 <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)">
365 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">367 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
366 <kbd>esc</kbd>368 <kbd>esc</kbd>
367 </button>369 </button>
@@ -440,7 +442,7 @@
440 k-esc from:body -> ermite.closeDialogs();442 k-esc from:body -> ermite.closeDialogs();
441 k-help from:body -> ermite.dialog('panel-help');443 k-help from:body -> ermite.dialog('panel-help');
442 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');444 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();
444 k-filter from:body -> ermite.focusFilter();446 k-filter from:body -> ermite.focusFilter();
445 k-gh from:body -> ermite.go('.brand');447 k-gh from:body -> ermite.go('.brand');
446 k-s from:body -> ermite.tab('s');448 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-root.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -587,7 +589,7 @@
587 <dt>589 <dt>
588 <kbd>/</kbd>590 <kbd>/</kbd>
589 </dt>591 </dt>
590-<dd>filter the list</dd>592+<dd>filter the list; on Files, find a file in the repository</dd>
591 </div>593 </div>
592 <div>594 <div>
593 <dt>595 <dt>
@@ -674,10 +676,10 @@
674 </section>676 </section>
675 </div>677 </div>
676 </dialog>678 </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()">
678 <div class="panel-head">680 <div class="panel-head">
679 <label for="cmd-q" class="sr">go to</label>681 <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)">
681 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">683 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
682 <kbd>esc</kbd>684 <kbd>esc</kbd>
683 </button>685 </button>
@@ -756,7 +758,7 @@
756 k-esc from:body -> ermite.closeDialogs();758 k-esc from:body -> ermite.closeDialogs();
757 k-help from:body -> ermite.dialog('panel-help');759 k-help from:body -> ermite.dialog('panel-help');
758 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');760 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();
760 k-filter from:body -> ermite.focusFilter();762 k-filter from:body -> ermite.focusFilter();
761 k-gh from:body -> ermite.go('.brand');763 k-gh from:body -> ermite.go('.brand');
762 k-s from:body -> ermite.tab('s');764 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commit-trojan.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -380,7 +382,7 @@
380 <dt>382 <dt>
381 <kbd>/</kbd>383 <kbd>/</kbd>
382 </dt>384 </dt>
383-<dd>filter the list</dd>385+<dd>filter the list; on Files, find a file in the repository</dd>
384 </div>386 </div>
385 <div>387 <div>
386 <dt>388 <dt>
@@ -467,10 +469,10 @@
467 </section>469 </section>
468 </div>470 </div>
469 </dialog>471 </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()">
471 <div class="panel-head">473 <div class="panel-head">
472 <label for="cmd-q" class="sr">go to</label>474 <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)">
474 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">476 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
475 <kbd>esc</kbd>477 <kbd>esc</kbd>
476 </button>478 </button>
@@ -549,7 +551,7 @@
549 k-esc from:body -> ermite.closeDialogs();551 k-esc from:body -> ermite.closeDialogs();
550 k-help from:body -> ermite.dialog('panel-help');552 k-help from:body -> ermite.dialog('panel-help');
551 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');553 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();
553 k-filter from:body -> ermite.focusFilter();555 k-filter from:body -> ermite.focusFilter();
554 k-gh from:body -> ermite.go('.brand');556 k-gh from:body -> ermite.go('.brand');
555 k-s from:body -> ermite.tab('s');557 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-page2.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -201,7 +203,7 @@
201 <dt>203 <dt>
202 <kbd>/</kbd>204 <kbd>/</kbd>
203 </dt>205 </dt>
204-<dd>filter the list</dd>206+<dd>filter the list; on Files, find a file in the repository</dd>
205 </div>207 </div>
206 <div>208 <div>
207 <dt>209 <dt>
@@ -280,10 +282,10 @@
280 </section>282 </section>
281 </div>283 </div>
282 </dialog>284 </dialog>
283-<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">285+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
284 <div class="panel-head">286 <div class="panel-head">
285 <label for="cmd-q" class="sr">go to</label>287 <label for="cmd-q" class="sr">go to</label>
286-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="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)">
287 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">289 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
288 <kbd>esc</kbd>290 <kbd>esc</kbd>
289 </button>291 </button>
@@ -356,7 +358,7 @@
356 k-esc from:body -> ermite.closeDialogs();358 k-esc from:body -> ermite.closeDialogs();
357 k-help from:body -> ermite.dialog('panel-help');359 k-help from:body -> ermite.dialog('panel-help');
358 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');360 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();
360 k-filter from:body -> ermite.focusFilter();362 k-filter from:body -> ermite.focusFilter();
361 k-gh from:body -> ermite.go('.brand');363 k-gh from:body -> ermite.go('.brand');
362 k-s from:body -> ermite.tab('s');364 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-path.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -216,7 +218,7 @@
216 <dt>218 <dt>
217 <kbd>/</kbd>219 <kbd>/</kbd>
218 </dt>220 </dt>
219-<dd>filter the list</dd>221+<dd>filter the list; on Files, find a file in the repository</dd>
220 </div>222 </div>
221 <div>223 <div>
222 <dt>224 <dt>
@@ -295,10 +297,10 @@
295 </section>297 </section>
296 </div>298 </div>
297 </dialog>299 </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()">
299 <div class="panel-head">301 <div class="panel-head">
300 <label for="cmd-q" class="sr">go to</label>302 <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)">
302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">304 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
303 <kbd>esc</kbd>305 <kbd>esc</kbd>
304 </button>306 </button>
@@ -371,7 +373,7 @@
371 k-esc from:body -> ermite.closeDialogs();373 k-esc from:body -> ermite.closeDialogs();
372 k-help from:body -> ermite.dialog('panel-help');374 k-help from:body -> ermite.dialog('panel-help');
373 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');375 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();
375 k-filter from:body -> ermite.focusFilter();377 k-filter from:body -> ermite.focusFilter();
376 k-gh from:body -> ermite.go('.brand');378 k-gh from:body -> ermite.go('.brand');
377 k-s from:body -> ermite.tab('s');379 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-root.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -1005,7 +1007,7 @@
1005 <dt>1007 <dt>
1006 <kbd>/</kbd>1008 <kbd>/</kbd>
1007 </dt>1009 </dt>
1008-<dd>filter the list</dd>1010+<dd>filter the list; on Files, find a file in the repository</dd>
1009 </div>1011 </div>
1010 <div>1012 <div>
1011 <dt>1013 <dt>
@@ -1084,10 +1086,10 @@
1084 </section>1086 </section>
1085 </div>1087 </div>
1086 </dialog>1088 </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()">
1088 <div class="panel-head">1090 <div class="panel-head">
1089 <label for="cmd-q" class="sr">go to</label>1091 <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)">
1091 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">1093 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
1092 <kbd>esc</kbd>1094 <kbd>esc</kbd>
1093 </button>1095 </button>
@@ -1160,7 +1162,7 @@
1160 k-esc from:body -> ermite.closeDialogs();1162 k-esc from:body -> ermite.closeDialogs();
1161 k-help from:body -> ermite.dialog('panel-help');1163 k-help from:body -> ermite.dialog('panel-help');
1162 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');1164 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();
1164 k-filter from:body -> ermite.focusFilter();1166 k-filter from:body -> ermite.focusFilter();
1165 k-gh from:body -> ermite.go('.brand');1167 k-gh from:body -> ermite.go('.brand');
1166 k-s from:body -> ermite.tab('s');1168 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/commits-small.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -350,7 +352,7 @@
350 <dt>352 <dt>
351 <kbd>/</kbd>353 <kbd>/</kbd>
352 </dt>354 </dt>
353-<dd>filter the list</dd>355+<dd>filter the list; on Files, find a file in the repository</dd>
354 </div>356 </div>
355 <div>357 <div>
356 <dt>358 <dt>
@@ -437,10 +439,10 @@
437 </section>439 </section>
438 </div>440 </div>
439 </dialog>441 </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()">
441 <div class="panel-head">443 <div class="panel-head">
442 <label for="cmd-q" class="sr">go to</label>444 <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)">
444 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">446 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
445 <kbd>esc</kbd>447 <kbd>esc</kbd>
446 </button>448 </button>
@@ -525,7 +527,7 @@
525 k-esc from:body -> ermite.closeDialogs();527 k-esc from:body -> ermite.closeDialogs();
526 k-help from:body -> ermite.dialog('panel-help');528 k-help from:body -> ermite.dialog('panel-help');
527 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');529 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();
529 k-filter from:body -> ermite.focusFilter();531 k-filter from:body -> ermite.focusFilter();
530 k-gh from:body -> ermite.go('.brand');532 k-gh from:body -> ermite.go('.brand');
531 k-s from:body -> ermite.tab('s');533 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/empty.html +6 -4
@@ -17,6 +17,8 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </head>
21 <body data-ref="" data-sha="" data-sel="" data-nav="">23 <body data-ref="" data-sha="" data-sel="" data-nav="">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
@@ -127,7 +129,7 @@
127 <dt>129 <dt>
128 <kbd>/</kbd>130 <kbd>/</kbd>
129 </dt>131 </dt>
130-<dd>filter the list</dd>132+<dd>filter the list; on Files, find a file in the repository</dd>
131 </div>133 </div>
132 <div>134 <div>
133 <dt>135 <dt>
@@ -179,10 +181,10 @@
179 </div>181 </div>
180 </dl>182 </dl>
181 </dialog>183 </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()">
183 <div class="panel-head">185 <div class="panel-head">
184 <label for="cmd-q" class="sr">go to</label>186 <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)">
186 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">188 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
187 <kbd>esc</kbd>189 <kbd>esc</kbd>
188 </button>190 </button>
@@ -219,7 +221,7 @@
219 k-esc from:body -> ermite.closeDialogs();221 k-esc from:body -> ermite.closeDialogs();
220 k-help from:body -> ermite.dialog('panel-help');222 k-help from:body -> ermite.dialog('panel-help');
221 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');223 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();
223 k-filter from:body -> ermite.focusFilter();225 k-filter from:body -> ermite.focusFilter();
224 k-gh from:body -> ermite.go('.brand');226 k-gh from:body -> ermite.go('.brand');
225 k-s from:body -> ermite.tab('s');227 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/home.html +6 -4
@@ -17,6 +17,8 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </head>
21 <body data-ref="" data-sha="" data-sel="" data-nav="">23 <body data-ref="" data-sha="" data-sel="" data-nav="">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
@@ -159,7 +161,7 @@
159 <dt>161 <dt>
160 <kbd>/</kbd>162 <kbd>/</kbd>
161 </dt>163 </dt>
162-<dd>filter the list</dd>164+<dd>filter the list; on Files, find a file in the repository</dd>
163 </div>165 </div>
164 <div>166 <div>
165 <dt>167 <dt>
@@ -211,10 +213,10 @@
211 </div>213 </div>
212 </dl>214 </dl>
213 </dialog>215 </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()">
215 <div class="panel-head">217 <div class="panel-head">
216 <label for="cmd-q" class="sr">go to</label>218 <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)">
218 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">220 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
219 <kbd>esc</kbd>221 <kbd>esc</kbd>
220 </button>222 </button>
@@ -257,7 +259,7 @@
257 k-esc from:body -> ermite.closeDialogs();259 k-esc from:body -> ermite.closeDialogs();
258 k-help from:body -> ermite.dialog('panel-help');260 k-help from:body -> ermite.dialog('panel-help');
259 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');261 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();
261 k-filter from:body -> ermite.focusFilter();263 k-filter from:body -> ermite.focusFilter();
262 k-gh from:body -> ermite.go('.brand');264 k-gh from:body -> ermite.go('.brand');
263 k-s from:body -> ermite.tab('s');265 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/not-found-page.html +6 -4
@@ -17,6 +17,8 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </head>
21 <body data-ref="" data-sha="" data-sel="" data-nav="">23 <body data-ref="" data-sha="" data-sel="" data-nav="">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
@@ -116,7 +118,7 @@
116 <dt>118 <dt>
117 <kbd>/</kbd>119 <kbd>/</kbd>
118 </dt>120 </dt>
119-<dd>filter the list</dd>121+<dd>filter the list; on Files, find a file in the repository</dd>
120 </div>122 </div>
121 <div>123 <div>
122 <dt>124 <dt>
@@ -168,10 +170,10 @@
168 </div>170 </div>
169 </dl>171 </dl>
170 </dialog>172 </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()">
172 <div class="panel-head">174 <div class="panel-head">
173 <label for="cmd-q" class="sr">go to</label>175 <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)">
175 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">177 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
176 <kbd>esc</kbd>178 <kbd>esc</kbd>
177 </button>179 </button>
@@ -202,7 +204,7 @@
202 k-esc from:body -> ermite.closeDialogs();204 k-esc from:body -> ermite.closeDialogs();
203 k-help from:body -> ermite.dialog('panel-help');205 k-help from:body -> ermite.dialog('panel-help');
204 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');206 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();
206 k-filter from:body -> ermite.focusFilter();208 k-filter from:body -> ermite.focusFilter();
207 k-gh from:body -> ermite.go('.brand');209 k-gh from:body -> ermite.go('.brand');
208 k-s from:body -> ermite.tab('s');210 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/not-found.html +6 -4
@@ -17,6 +17,8 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </head>
21 <body data-ref="" data-sha="" data-sel="" data-nav="">23 <body data-ref="" data-sha="" data-sel="" data-nav="">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
@@ -116,7 +118,7 @@
116 <dt>118 <dt>
117 <kbd>/</kbd>119 <kbd>/</kbd>
118 </dt>120 </dt>
119-<dd>filter the list</dd>121+<dd>filter the list; on Files, find a file in the repository</dd>
120 </div>122 </div>
121 <div>123 <div>
122 <dt>124 <dt>
@@ -168,10 +170,10 @@
168 </div>170 </div>
169 </dl>171 </dl>
170 </dialog>172 </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()">
172 <div class="panel-head">174 <div class="panel-head">
173 <label for="cmd-q" class="sr">go to</label>175 <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)">
175 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">177 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
176 <kbd>esc</kbd>178 <kbd>esc</kbd>
177 </button>179 </button>
@@ -202,7 +204,7 @@
202 k-esc from:body -> ermite.closeDialogs();204 k-esc from:body -> ermite.closeDialogs();
203 k-help from:body -> ermite.dialog('panel-help');205 k-help from:body -> ermite.dialog('panel-help');
204 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');206 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();
206 k-filter from:body -> ermite.focusFilter();208 k-filter from:body -> ermite.focusFilter();
207 k-gh from:body -> ermite.go('.brand');209 k-gh from:body -> ermite.go('.brand');
208 k-s from:body -> ermite.tab('s');210 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/refs.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -242,7 +244,7 @@
242 <dt>244 <dt>
243 <kbd>/</kbd>245 <kbd>/</kbd>
244 </dt>246 </dt>
245-<dd>filter the list</dd>247+<dd>filter the list; on Files, find a file in the repository</dd>
246 </div>248 </div>
247 <div>249 <div>
248 <dt>250 <dt>
@@ -333,10 +335,10 @@
333 </section>335 </section>
334 </div>336 </div>
335 </dialog>337 </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()">
337 <div class="panel-head">339 <div class="panel-head">
338 <label for="cmd-q" class="sr">go to</label>340 <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)">
340 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">342 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
341 <kbd>esc</kbd>343 <kbd>esc</kbd>
342 </button>344 </button>
@@ -427,7 +429,7 @@
427 k-esc from:body -> ermite.closeDialogs();429 k-esc from:body -> ermite.closeDialogs();
428 k-help from:body -> ermite.dialog('panel-help');430 k-help from:body -> ermite.dialog('panel-help');
429 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');431 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();
431 k-filter from:body -> ermite.focusFilter();433 k-filter from:body -> ermite.focusFilter();
432 k-gh from:body -> ermite.go('.brand');434 k-gh from:body -> ermite.go('.brand');
433 k-s from:body -> ermite.tab('s');435 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/summary.html +7 -5
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -261,7 +263,7 @@
261 <dt>263 <dt>
262 <kbd>/</kbd>264 <kbd>/</kbd>
263 </dt>265 </dt>
264-<dd>filter the list</dd>266+<dd>filter the list; on Files, find a file in the repository</dd>
265 </div>267 </div>
266 <div>268 <div>
267 <dt>269 <dt>
@@ -352,10 +354,10 @@
352 </section>354 </section>
353 </div>355 </div>
354 </dialog>356 </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()">
356 <div class="panel-head">358 <div class="panel-head">
357 <label for="cmd-q" class="sr">go to</label>359 <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)">
359 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">361 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
360 <kbd>esc</kbd>362 <kbd>esc</kbd>
361 </button>363 </button>
@@ -446,7 +448,7 @@
446 k-esc from:body -> ermite.closeDialogs();448 k-esc from:body -> ermite.closeDialogs();
447 k-help from:body -> ermite.dialog('panel-help');449 k-help from:body -> ermite.dialog('panel-help');
448 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');450 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();
450 k-filter from:body -> ermite.focusFilter();452 k-filter from:body -> ermite.focusFilter();
451 k-gh from:body -> ermite.go('.brand');453 k-gh from:body -> ermite.go('.brand');
452 k-s from:body -> ermite.tab('s');454 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-nested.html +18 -14
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -90,9 +92,16 @@
90 <div class="work">92 <div class="work">
91 <main id="main" tabindex="-1">93 <main id="main" tabindex="-1">
92 <div class="filter">94 <div class="filter">
93-<label for="tree-filter" class="sr">filter</label>95+<label for="finder-q" class="sr">find a file</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)">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>
95 </div>103 </div>
104+<div id="tree-view">
96 <table class="list tree" aria-label="Entries">105 <table class="list tree" aria-label="Entries">
97 <thead>106 <thead>
98 <tr>107 <tr>
@@ -107,7 +116,7 @@
107 <th class="c-when">date</th>116 <th class="c-when">date</th>
108 </tr>117 </tr>
109 </thead>118 </thead>
110-<tbody id="tree-rows" data-rows>119+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
111 <tr data-cursor>120 <tr data-cursor>
112 <td class="cur" aria-hidden="true">121 <td class="cur" aria-hidden="true">
113 </td>122 </td>
@@ -126,6 +135,7 @@
126 </tr>135 </tr>
127 </tbody>136 </tbody>
128 </table>137 </table>
138+</div>
129 </main>139 </main>
130 </div>140 </div>
131 <footer class="foot">141 <footer class="foot">
@@ -186,7 +196,7 @@
186 <dt>196 <dt>
187 <kbd>/</kbd>197 <kbd>/</kbd>
188 </dt>198 </dt>
189-<dd>filter the list</dd>199+<dd>filter the list; on Files, find a file in the repository</dd>
190 </div>200 </div>
191 <div>201 <div>
192 <dt>202 <dt>
@@ -273,10 +283,10 @@
273 </section>283 </section>
274 </div>284 </div>
275 </dialog>285 </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()">
277 <div class="panel-head">287 <div class="panel-head">
278 <label for="cmd-q" class="sr">go to</label>288 <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)">
280 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">290 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
281 <kbd>esc</kbd>291 <kbd>esc</kbd>
282 </button>292 </button>
@@ -336,12 +346,6 @@
336 <span class="k">tag</span>346 <span class="k">tag</span>
337 </a>347 </a>
338 </li>348 </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>
345 </ul>349 </ul>
346 <p class="panel-foot">350 <p class="panel-foot">
347 <span>351 <span>
@@ -361,7 +365,7 @@
361 k-esc from:body -> ermite.closeDialogs();365 k-esc from:body -> ermite.closeDialogs();
362 k-help from:body -> ermite.dialog('panel-help');366 k-help from:body -> ermite.dialog('panel-help');
363 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');367 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();
365 k-filter from:body -> ermite.focusFilter();369 k-filter from:body -> ermite.focusFilter();
366 k-gh from:body -> ermite.go('.brand');370 k-gh from:body -> ermite.go('.brand');
367 k-s from:body -> ermite.tab('s');371 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-root.html +22 -90
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -84,9 +86,16 @@
84 <div class="work">86 <div class="work">
85 <main id="main" tabindex="-1">87 <main id="main" tabindex="-1">
86 <div class="filter">88 <div class="filter">
87-<label for="tree-filter" class="sr">filter</label>89+<label for="finder-q" class="sr">find a file</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)">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>
89 </div>97 </div>
98+<div id="tree-view">
90 <table class="list tree" aria-label="Entries">99 <table class="list tree" aria-label="Entries">
91 <thead>100 <thead>
92 <tr>101 <tr>
@@ -101,13 +110,13 @@
101 <th class="c-when">date</th>110 <th class="c-when">date</th>
102 </tr>111 </tr>
103 </thead>112 </thead>
104-<tbody id="tree-rows" data-rows>113+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
105 <tr data-cursor>114 <tr data-cursor>
106 <td class="cur" aria-hidden="true">115 <td class="cur" aria-hidden="true">
107 </td>116 </td>
108-<td class="c-kind" aria-hidden="true">▾</td>117+<td class="c-kind" aria-hidden="true">▸</td>
109 <td class="c-name">118 <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>
111 </td>120 </td>
112 <td class="c-size num dim">-</td>121 <td class="c-size num dim">-</td>
113 <td class="c-sub dim">122 <td class="c-sub dim">
@@ -121,9 +130,9 @@
121 <tr>130 <tr>
122 <td class="cur" aria-hidden="true">131 <td class="cur" aria-hidden="true">
123 </td>132 </td>
124-<td class="c-kind" aria-hidden="true">▾</td>133+<td class="c-kind" aria-hidden="true">▸</td>
125 <td class="c-name">134 <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>
127 </td>136 </td>
128 <td class="c-size num dim">-</td>137 <td class="c-size num dim">-</td>
129 <td class="c-sub dim">138 <td class="c-sub dim">
@@ -353,6 +362,7 @@
353 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>362 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
354 </article>363 </article>
355 </section>364 </section>
365+</div>
356 </main>366 </main>
357 </div>367 </div>
358 <footer class="foot">368 <footer class="foot">
@@ -413,7 +423,7 @@
413 <dt>423 <dt>
414 <kbd>/</kbd>424 <kbd>/</kbd>
415 </dt>425 </dt>
416-<dd>filter the list</dd>426+<dd>filter the list; on Files, find a file in the repository</dd>
417 </div>427 </div>
418 <div>428 <div>
419 <dt>429 <dt>
@@ -500,10 +510,10 @@
500 </section>510 </section>
501 </div>511 </div>
502 </dialog>512 </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()">
504 <div class="panel-head">514 <div class="panel-head">
505 <label for="cmd-q" class="sr">go to</label>515 <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)">
507 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">517 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
508 <kbd>esc</kbd>518 <kbd>esc</kbd>
509 </button>519 </button>
@@ -563,84 +573,6 @@
563 <span class="k">tag</span>573 <span class="k">tag</span>
564 </a>574 </a>
565 </li>575 </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>
644 </ul>576 </ul>
645 <p class="panel-foot">577 <p class="panel-foot">
646 <span>578 <span>
@@ -660,7 +592,7 @@
660 k-esc from:body -> ermite.closeDialogs();592 k-esc from:body -> ermite.closeDialogs();
661 k-help from:body -> ermite.dialog('panel-help');593 k-help from:body -> ermite.dialog('panel-help');
662 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');594 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();
664 k-filter from:body -> ermite.focusFilter();596 k-filter from:body -> ermite.focusFilter();
665 k-gh from:body -> ermite.go('.brand');597 k-gh from:body -> ermite.go('.brand');
666 k-s from:body -> ermite.tab('s');598 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-slash-ref.html +18 -14
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -87,9 +89,16 @@
87 <div class="work">89 <div class="work">
88 <main id="main" tabindex="-1">90 <main id="main" tabindex="-1">
89 <div class="filter">91 <div class="filter">
90-<label for="tree-filter" class="sr">filter</label>92+<label for="finder-q" class="sr">find a file</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)">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>
92 </div>100 </div>
101+<div id="tree-view">
93 <table class="list tree" aria-label="Entries">102 <table class="list tree" aria-label="Entries">
94 <thead>103 <thead>
95 <tr>104 <tr>
@@ -104,7 +113,7 @@
104 <th class="c-when">date</th>113 <th class="c-when">date</th>
105 </tr>114 </tr>
106 </thead>115 </thead>
107-<tbody id="tree-rows" data-rows>116+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
108 <tr data-cursor>117 <tr data-cursor>
109 <td class="cur" aria-hidden="true">118 <td class="cur" aria-hidden="true">
110 </td>119 </td>
@@ -123,6 +132,7 @@
123 </tr>132 </tr>
124 </tbody>133 </tbody>
125 </table>134 </table>
135+</div>
126 </main>136 </main>
127 </div>137 </div>
128 <footer class="foot">138 <footer class="foot">
@@ -183,7 +193,7 @@
183 <dt>193 <dt>
184 <kbd>/</kbd>194 <kbd>/</kbd>
185 </dt>195 </dt>
186-<dd>filter the list</dd>196+<dd>filter the list; on Files, find a file in the repository</dd>
187 </div>197 </div>
188 <div>198 <div>
189 <dt>199 <dt>
@@ -270,10 +280,10 @@
270 </section>280 </section>
271 </div>281 </div>
272 </dialog>282 </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()">
274 <div class="panel-head">284 <div class="panel-head">
275 <label for="cmd-q" class="sr">go to</label>285 <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)">
277 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">287 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
278 <kbd>esc</kbd>288 <kbd>esc</kbd>
279 </button>289 </button>
@@ -333,12 +343,6 @@
333 <span class="k">tag</span>343 <span class="k">tag</span>
334 </a>344 </a>
335 </li>345 </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>
342 </ul>346 </ul>
343 <p class="panel-foot">347 <p class="panel-foot">
344 <span>348 <span>
@@ -358,7 +362,7 @@
358 k-esc from:body -> ermite.closeDialogs();362 k-esc from:body -> ermite.closeDialogs();
359 k-help from:body -> ermite.dialog('panel-help');363 k-help from:body -> ermite.dialog('panel-help');
360 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');364 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();
362 k-filter from:body -> ermite.focusFilter();366 k-filter from:body -> ermite.focusFilter();
363 k-gh from:body -> ermite.go('.brand');367 k-gh from:body -> ermite.go('.brand');
364 k-s from:body -> ermite.tab('s');368 k-s from:body -> ermite.tab('s');
M internal/app/testdata/web/tree-subdir.html +18 -20
@@ -17,8 +17,10 @@
17 </script>17 </script>
18 <script src="/-/static/js/ermite.HASH.js" defer>18 <script src="/-/static/js/ermite.HASH.js" defer>
19 </script>19 </script>
20+<script src="/-/static/js/files.HASH.js" defer>
21+</script>
20 </head>22 </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">
22 <a class="skip" href="#main">skip to content</a>24 <a class="skip" href="#main">skip to content</a>
23 <header class="hdr">25 <header class="hdr">
24 <div class="hdr-top">26 <div class="hdr-top">
@@ -87,9 +89,16 @@
87 <div class="work">89 <div class="work">
88 <main id="main" tabindex="-1">90 <main id="main" tabindex="-1">
89 <div class="filter">91 <div class="filter">
90-<label for="tree-filter" class="sr">filter</label>92+<label for="finder-q" class="sr">find a file</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)">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)">
92 </div>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>
100+</div>
101+<div id="tree-view">
93 <table class="list tree" aria-label="Entries">102 <table class="list tree" aria-label="Entries">
94 <thead>103 <thead>
95 <tr>104 <tr>
@@ -104,7 +113,7 @@
104 <th class="c-when">date</th>113 <th class="c-when">date</th>
105 </tr>114 </tr>
106 </thead>115 </thead>
107-<tbody id="tree-rows" data-rows>116+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
108 <tr data-cursor>117 <tr data-cursor>
109 <td class="cur" aria-hidden="true">118 <td class="cur" aria-hidden="true">
110 </td>119 </td>
@@ -148,6 +157,7 @@
148 <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>157 <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>
149 </article>158 </article>
150 </section>159 </section>
160+</div>
151 </main>161 </main>
152 </div>162 </div>
153 <footer class="foot">163 <footer class="foot">
@@ -208,7 +218,7 @@
208 <dt>218 <dt>
209 <kbd>/</kbd>219 <kbd>/</kbd>
210 </dt>220 </dt>
211-<dd>filter the list</dd>221+<dd>filter the list; on Files, find a file in the repository</dd>
212 </div>222 </div>
213 <div>223 <div>
214 <dt>224 <dt>
@@ -295,10 +305,10 @@
295 </section>305 </section>
296 </div>306 </div>
297 </dialog>307 </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()">
299 <div class="panel-head">309 <div class="panel-head">
300 <label for="cmd-q" class="sr">go to</label>310 <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)">
302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">312 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
303 <kbd>esc</kbd>313 <kbd>esc</kbd>
304 </button>314 </button>
@@ -358,18 +368,6 @@
358 <span class="k">tag</span>368 <span class="k">tag</span>
359 </a>369 </a>
360 </li>370 </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>
373 </ul>371 </ul>
374 <p class="panel-foot">372 <p class="panel-foot">
375 <span>373 <span>
@@ -389,7 +387,7 @@
389 k-esc from:body -> ermite.closeDialogs();387 k-esc from:body -> ermite.closeDialogs();
390 k-help from:body -> ermite.dialog('panel-help');388 k-help from:body -> ermite.dialog('panel-help');
391 k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');389 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();
393 k-filter from:body -> ermite.focusFilter();391 k-filter from:body -> ermite.focusFilter();
394 k-gh from:body -> ermite.go('.brand');392 k-gh from:body -> ermite.go('.brand');
395 k-s from:body -> ermite.tab('s');393 k-s from:body -> ermite.tab('s');
M internal/app/web_files_test.go +255 -0
@@ -4,10 +4,16 @@ package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "compress/gzip"
8+ "encoding/json"
9+ "fmt"
7 "image"10 "image"
8 "image/color"11 "image/color"
9 "image/png"12 "image/png"
13+ "io"
10 "net/http"14 "net/http"
15+ "os"
16+ "path/filepath"
11 "regexp"17 "regexp"
12 "strconv"18 "strconv"
13 "strings"19 "strings"
@@ -100,6 +106,78 @@ func TestWebFilesGolden(t *testing.T) {
100 }106 }
101 }107 }
102 108
109+// AC1, AC2, AC7: ?rows answers the entries of a directory as table rows only,
110+// for the Files view to insert under the directory; a private repository is
111+// the 404 of a missing one.
112+func TestWebTreeRows(t *testing.T) {
113+ h := newHarness(t, harnessOpts{})
114+ h.webFilesRepo("files")
115+ h.seed("secret-sauce", domain.VisibilityPrivate, "dir/a.txt")
116+
117+ // The page links its directories to their rows.
118+ _, _, page := h.get("/files/tree/main")
119+ assert.Contains(t, page, `href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false"`)
120+ assert.Contains(t, page, `<script src="/-/static/js/files.`)
121+ assert.NotContains(t, page, "main.go</span>", "the palette lists no file")
122+
123+ status, hdr, body := h.get("/files/tree/main/docs?rows")
124+ require.Equal(t, 200, status)
125+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
126+ assert.Contains(t, hdr.Get("Content-Type"), "text/html")
127+ assert.NotContains(t, body, "<html")
128+ assert.NotContains(t, body, "<table")
129+ assert.Equal(t, 2, strings.Count(body, "<tr"), "one row per entry")
130+ assert.Contains(t, body, `<a href="/files/blob/main/docs/README.md">README.md</a>`)
131+ assert.Contains(t, body, `<a href="/files/blob/main/docs/guide.md">guide.md</a>`)
132+ assert.NotContains(t, body, "data-cursor", "the cursor stays on the page")
133+ assertNoInline(t, body)
134+
135+ // A subdirectory row can be opened in turn.
136+ _, _, body = h.get("/files/tree/main/src?rows")
137+ assert.Contains(t, body, `href="/files/tree/main/src/lib" data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`)
138+ assert.Contains(t, body, "▸")
139+ _, _, body = h.get("/files/tree/main/src/lib?rows")
140+ assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`)
141+ assert.NotContains(t, body, "data-expand")
142+
143+ // Not found, and private as anonymous: the 404 page, nothing of the rows.
144+ for _, p := range []string{"/files/tree/main/nope?rows", "/secret-sauce/tree/main/dir?rows", "/secret-sauce/tree/main?rows"} {
145+ status, _, body = h.get(p)
146+ assert.Equal(t, 404, status, p)
147+ assert.NotContains(t, body, "a.txt", p)
148+ }
149+}
150+
151+// ?rows on a directory with more entries than are listed ends with the row
152+// that links to the directory page, which says the listing is cut.
153+func TestWebTreeRowsTruncated(t *testing.T) {
154+ h := newHarness(t, harnessOpts{})
155+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), 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+
103 // Ref names with a slash are found by their longest prefix, and a commit id181 // Ref names with a slash are found by their longest prefix, and a commit id
104 // works where a ref does.182 // works where a ref does.
105 func TestWebFilesRefs(t *testing.T) {183 func TestWebFilesRefs(t *testing.T) {
@@ -248,6 +326,19 @@ func TestWebRaw(t *testing.T) {
248 require.NoError(t, err)326 require.NoError(t, err)
249 _ = resp.Body.Close()327 _ = resp.Body.Close()
250 assert.Equal(t, http.StatusNotModified, resp.StatusCode)328 assert.Equal(t, http.StatusNotModified, resp.StatusCode)
329+ // A list of validators, weak ones and "*" match as well.
330+ for _, inm := range []string{`"other", ` + etag, "W/" + etag, "*"} {
331+ req.Header.Set("If-None-Match", inm)
332+ resp, err = http.DefaultClient.Do(req)
333+ require.NoError(t, err)
334+ _ = resp.Body.Close()
335+ assert.Equal(t, http.StatusNotModified, resp.StatusCode, inm)
336+ }
337+ req.Header.Set("If-None-Match", `"other", "another"`)
338+ resp, err = http.DefaultClient.Do(req)
339+ require.NoError(t, err)
340+ _ = resp.Body.Close()
341+ assert.Equal(t, http.StatusOK, resp.StatusCode, "no validator matches")
251 assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil))342 assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil))
252 }343 }
253 344
@@ -331,3 +422,167 @@ func TestWebFilesContentSecurity(t *testing.T) {
331 assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")422 assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")
332 assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)423 assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)
333 }424 }
425+
426+// AC4: GET /<repo>/paths/<sha> lists the file paths of a commit as gzipped
427+// JSON, behind the access rule on every request, even when the list is
428+// already cached; a visitor revalidates (no-cache + ETag), the owner's copy is
429+// private.
430+func TestWebPaths(t *testing.T) {
431+ c := newCLIHarness(t)
432+ h := c.harness
433+ h.webFilesRepo("files")
434+ sha := h.bareGit("files", "rev-parse", "main")
435+
436+ var want []string
437+ for _, p := range strings.Split(h.bareGit("files", "ls-tree", "-r", "-z", "--name-only", "main"), "\x00") {
438+ if p != "" {
439+ want = append(want, p)
440+ }
441+ }
442+ require.NotEmpty(t, want)
443+ b := browser{h}
444+ path := "/files/paths/" + sha
445+ type list struct {
446+ Paths []string `json:"paths"`
447+ Truncated bool `json:"truncated"`
448+ }
449+ decodeList := func(t *testing.T, body string) list {
450+ t.Helper()
451+ var l list
452+ require.NoError(t, json.Unmarshal([]byte(body), &l))
453+ return l
454+ }
455+ gunzip := func(t *testing.T, body string) string {
456+ t.Helper()
457+ zr, err := gzip.NewReader(strings.NewReader(body))
458+ require.NoError(t, err)
459+ out, err := io.ReadAll(zr)
460+ require.NoError(t, err)
461+ return string(out)
462+ }
463+
464+ // Anonymous with gzip: the cached bytes as they are. The client of the
465+ // harness would unzip by itself, so the encoding is asked for explicitly
466+ // through a transport that does not.
467+ raw := func(hdr map[string]string) page {
468+ req, err := http.NewRequest(http.MethodGet, h.url(path), nil)
469+ require.NoError(t, err)
470+ for k, v := range hdr {
471+ req.Header.Set(k, v)
472+ }
473+ resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
474+ require.NoError(t, err)
475+ defer func() { _ = resp.Body.Close() }()
476+ out, err := io.ReadAll(resp.Body)
477+ require.NoError(t, err)
478+ return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
479+ }
480+ gz := raw(map[string]string{"Accept-Encoding": "gzip"})
481+ require.Equal(t, 200, gz.status)
482+ assert.Equal(t, "gzip", gz.hdr.Get("Content-Encoding"))
483+ assert.Equal(t, "application/json", gz.hdr.Get("Content-Type"))
484+ assert.Equal(t, "nosniff", gz.hdr.Get("X-Content-Type-Options"))
485+ assert.Equal(t, "no-cache", gz.hdr.Get("Cache-Control"))
486+ assert.Equal(t, "Accept-Encoding", gz.hdr.Get("Vary"))
487+ etag := gz.hdr.Get("ETag")
488+ assert.Equal(t, `"paths-`+sha+`-gz"`, etag)
489+ got := decodeList(t, gunzip(t, gz.body))
490+ assert.Equal(t, want, got.Paths, "git ls-tree -r order")
491+ assert.False(t, got.Truncated)
492+
493+ // Without gzip: plain JSON, the same list.
494+ plain := raw(nil)
495+ require.Equal(t, 200, plain.status)
496+ assert.Empty(t, plain.hdr.Get("Content-Encoding"))
497+ assert.Equal(t, want, decodeList(t, plain.body).Paths)
498+ assert.Equal(t, `"paths-`+sha+`"`, plain.hdr.Get("ETag"), "another variant, another validator")
499+
500+ // Revalidation: 304, still through the access rule. The matrix of
501+ // validators (variants, weak, "*") is in paths_test.go.
502+ nm := raw(map[string]string{"If-None-Match": etag, "Accept-Encoding": "gzip"})
503+ assert.Equal(t, http.StatusNotModified, nm.status)
504+ assert.Empty(t, nm.body)
505+ assert.Equal(t, "no-cache", nm.hdr.Get("Cache-Control"))
506+
507+ // Only a full commit id: abbreviations, refs, upper case and unknown ids.
508+ for _, p := range []string{
509+ "/files/paths/" + sha[:7], "/files/paths/" + sha[:39], "/files/paths/main", "/files/paths/" + strings.ToUpper(sha),
510+ "/files/paths/" + strings.Repeat("0", 40), "/files/paths/", "/never/paths/" + sha,
511+ } {
512+ assert.Equal(t, 404, b.get(p, nil).status, p)
513+ }
514+
515+ // Another repository does not serve a commit it does not have, though
516+ // the list of that commit is cached.
517+ h.seed("other", domain.VisibilityPublic, "x.txt")
518+ assert.Equal(t, 404, b.get("/other/paths/"+sha, nil).status)
519+
520+ // The owner: private, never stored; the list is the same.
521+ token := c.signIn(t)
522+ own := b.get(path, as(token))
523+ require.Equal(t, 200, own.status)
524+ assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
525+ assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
526+ assert.Equal(t, want, decodeList(t, own.body).Paths)
527+
528+ // The repository turns private: the list is cached on the server, and
529+ // anonymous gets the 404 of a missing repository, as the owner still
530+ // reads it.
531+ priv := domain.VisibilityPrivate
532+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), 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) {
155 if s == "" {155 if s == "" {
156 return TreePath{}, nil156 return TreePath{}, nil
157 }157 }
158+ if why := treePathProblem(s); why != "" {
159+ return TreePath{}, fmt.Errorf("%w: %q: %s", ErrInvalidPath, truncate(s), why)
160+ }
161+ return TreePath{s: s}, nil
162+}
163+
164+// IsAddressablePath reports whether s is a non-root path that ParseTreePath
165+// accepts as it is (no trailing slash). It allocates nothing.
166+func IsAddressablePath(s string) bool {
167+ return s != "" && treePathProblem(s) == ""
168+}
169+
170+// IsAddressablePathBytes is IsAddressablePath for a path held in bytes; it
171+// spares the copy into a string.
172+func IsAddressablePathBytes(b []byte) bool {
173+ return len(b) > 0 && len(b) <= maxTreePathLen && utf8.Valid(b) && pathShapeProblem(b) == ""
174+}
175+
176+// treePathProblem returns why s cannot be a non-root tree path, or "".
177+func treePathProblem(s string) string {
158 if len(s) > maxTreePathLen || !utf8.ValidString(s) {178 if len(s) > maxTreePathLen || !utf8.ValidString(s) {
159- return TreePath{}, fmt.Errorf("%w: %q: too long or not valid UTF-8", ErrInvalidPath, truncate(s))179+ return "too long or not valid UTF-8"
160 }180 }
161- for _, c := range s {181+ return pathShapeProblem(s)
162- if c < 0x20 || c == 0x7f {182+}
163- return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s))183+
184+// pathShapeProblem checks the control characters and the components of a path
185+// that is valid UTF-8 and short enough. The bytes of a multi-byte character
186+// are all 0x80 or more, so it looks at bytes.
187+func pathShapeProblem[S ~string | ~[]byte](s S) string {
188+ start := 0
189+ for i := 0; i <= len(s); i++ {
190+ if i < len(s) && s[i] != '/' {
191+ if c := s[i]; c < 0x20 || c == 0x7f {
192+ return "control character"
193+ }
194+ continue
164 }195 }
165- }196+ n := i - start
166- for part := range strings.SplitSeq(s, "/") {197+ if n == 0 || (n == 1 && s[start] == '.') || (n == 2 && s[start] == '.' && s[start+1] == '.') {
167- if part == "" || part == "." || part == ".." {198+ return "empty, . or .. component"
168- return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s))
169 }199 }
200+ start = i + 1
170 }201 }
171- return TreePath{s: s}, nil202+ return ""
172 }203 }
173 204
174 func truncate(s string) string {205 func truncate(s string) string {
@@ -325,6 +356,45 @@ type Tree struct {
325 // MaxTreeEntries bounds the entries of a listed directory.356 // MaxTreeEntries bounds the entries of a listed directory.
326 const MaxTreeEntries = 5000357 const MaxTreeEntries = 5000
327 358
359+// MaxPathEntries bounds the paths ListPaths returns.
360+const MaxPathEntries = 100_000
361+
362+// MaxPathBytes bounds the total bytes of the paths ListPaths returns.
363+const MaxPathBytes = 8 << 20
364+
365+// PathList is the recursive list of the file paths of a revision.
366+type PathList struct {
367+ // CommitID is the commit the revision resolved to.
368+ CommitID ObjectID
369+ // TreeID is the root tree of that commit.
370+ TreeID ObjectID
371+ // Paths are the paths of the files and symbolic links, in tree order (the
372+ // order of `git ls-tree -r`).
373+ Paths []string
374+ // Truncated is true when more paths exist than the ones in Paths.
375+ Truncated bool
376+
377+ size int // bytes of Paths, kept by Add
378+}
379+
380+// Add lists p, the next path of a walk in tree order, within the bounds of
381+// ListPaths: at most maxPaths paths and maxBytes bytes of paths. A path that
382+// is not addressable (IsAddressablePath) is left out. When a bound is reached
383+// p is not listed, Truncated is set, and Add returns false: the walk can stop.
384+// Otherwise it returns true. The bytes of p are kept, not copied.
385+func (l *PathList) Add(p string, maxPaths, maxBytes int) bool {
386+ if !IsAddressablePath(p) {
387+ return true
388+ }
389+ if len(l.Paths) >= maxPaths || l.size+len(p) > maxBytes {
390+ l.Truncated = true
391+ return false
392+ }
393+ l.size += len(p)
394+ l.Paths = append(l.Paths, p)
395+ return true
396+}
397+
328 // MaxLastCommitEntries bounds the entries of one directory that get a last398 // MaxLastCommitEntries bounds the entries of one directory that get a last
329 // commit: each costs a history lookup.399 // commit: each costs a history lookup.
330 const MaxLastCommitEntries = 200400 const MaxLastCommitEntries = 200
M internal/domain/git_test.go +72 -3
@@ -77,10 +77,79 @@ func TestParseTreePath(t *testing.T) {
77 require.NoError(t, err)77 require.NoError(t, err)
78 assert.Equal(t, "a/b", p.String())78 assert.Equal(t, "a/b", p.String())
79 assert.Equal(t, "a/b/c", p.Join("c"))79 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+}
80 119
81- for _, bad := range []string{"/a", "a//b", "./a", "a/..", "..", ".", "a\x00b", "a\nb", "\xff", strings.Repeat("a", 4097), "//"} {120+func TestPathListAdd(t *testing.T) {
82- _, err := domain.ParseTreePath(bad)121+ for _, tt := range []struct {
83- assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", bad)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+ })
84 }153 }
85 }154 }
86 155
M internal/ports/mocks/BrowseService.go +78 -0
@@ -295,6 +295,84 @@ func (_c *BrowseService_ListCommits_Call) RunAndReturn(run func(ctx context.Cont
295 return _c295 return _c
296 }296 }
297 297
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+
298 // ListRefs provides a mock function for the type BrowseService376 // ListRefs provides a mock function for the type BrowseService
299 func (_mock *BrowseService) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) {377 func (_mock *BrowseService) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) {
300 ret := _mock.Called(ctx, p, repo, params)378 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
283 return _c283 return _c
284 }284 }
285 285
286+// ListPaths provides a mock function for the type RepoReader
287+func (_mock *RepoReader) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error) {
288+ ret := _mock.Called(ctx, repo, rev, maxPaths, maxBytes)
289+
290+ if len(ret) == 0 {
291+ panic("no return value specified for ListPaths")
292+ }
293+
294+ var r0 domain.PathList
295+ var r1 error
296+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) (domain.PathList, error)); ok {
297+ return returnFunc(ctx, repo, rev, maxPaths, maxBytes)
298+ }
299+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) domain.PathList); ok {
300+ r0 = returnFunc(ctx, repo, rev, maxPaths, maxBytes)
301+ } else {
302+ r0 = ret.Get(0).(domain.PathList)
303+ }
304+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, int, int) error); ok {
305+ r1 = returnFunc(ctx, repo, rev, maxPaths, maxBytes)
306+ } else {
307+ r1 = ret.Error(1)
308+ }
309+ return r0, r1
310+}
311+
312+// RepoReader_ListPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPaths'
313+type RepoReader_ListPaths_Call struct {
314+ *mock.Call
315+}
316+
317+// ListPaths is a helper method to define mock.On call
318+// - ctx context.Context
319+// - repo domain.RepoName
320+// - rev domain.Revision
321+// - maxPaths int
322+// - maxBytes int
323+func (_e *RepoReader_Expecter) ListPaths(ctx any, repo any, rev any, maxPaths any, maxBytes any) *RepoReader_ListPaths_Call {
324+ return &RepoReader_ListPaths_Call{Call: _e.mock.On("ListPaths", ctx, repo, rev, maxPaths, maxBytes)}
325+}
326+
327+func (_c *RepoReader_ListPaths_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int)) *RepoReader_ListPaths_Call {
328+ _c.Call.Run(func(args mock.Arguments) {
329+ var arg0 context.Context
330+ if args[0] != nil {
331+ arg0 = args[0].(context.Context)
332+ }
333+ var arg1 domain.RepoName
334+ if args[1] != nil {
335+ arg1 = args[1].(domain.RepoName)
336+ }
337+ var arg2 domain.Revision
338+ if args[2] != nil {
339+ arg2 = args[2].(domain.Revision)
340+ }
341+ var arg3 int
342+ if args[3] != nil {
343+ arg3 = args[3].(int)
344+ }
345+ var arg4 int
346+ if args[4] != nil {
347+ arg4 = args[4].(int)
348+ }
349+ run(
350+ arg0,
351+ arg1,
352+ arg2,
353+ arg3,
354+ arg4,
355+ )
356+ })
357+ return _c
358+}
359+
360+func (_c *RepoReader_ListPaths_Call) Return(pathList domain.PathList, err error) *RepoReader_ListPaths_Call {
361+ _c.Call.Return(pathList, err)
362+ return _c
363+}
364+
365+func (_c *RepoReader_ListPaths_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error)) *RepoReader_ListPaths_Call {
366+ _c.Call.Return(run)
367+ return _c
368+}
369+
286 // ListRefs provides a mock function for the type RepoReader370 // ListRefs provides a mock function for the type RepoReader
287 func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {371 func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
288 ret := _mock.Called(ctx, repo)372 ret := _mock.Called(ctx, repo)
M internal/ports/ports.go +5 -0
@@ -163,6 +163,11 @@ type RepoReader interface {
163 // domain.MaxLastCommitEntries entries get their last commit. It returns163 // domain.MaxLastCommitEntries entries get their last commit. It returns
164 // domain.ErrNotDirectory if path names something else.164 // domain.ErrNotDirectory if path names something else.
165 GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)165 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)
166 // OpenBlob returns the description of the file at path and a reader of its171 // OpenBlob returns the description of the file at path and a reader of its
167 // content, which streams without holding the whole file in memory. The172 // content, which streams without holding the whole file in memory. The
168 // caller must close the reader. It returns domain.ErrNotFile if path names173 // 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 {
61 ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error)61 ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error)
62 ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)62 ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)
63 GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)63 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)
64 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)65 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
65 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)66 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
66 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)67 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
264 }264 }
265 }265 }
266 })266 })
267+ t.Run("ListPaths", func(t *testing.T) {
268+ for _, rev := range revs {
269+ for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} {
270+ w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1])
271+ g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1])
272+ if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) {
273+ assert.Equal(t, w, g, "paths at %q %v", rev, lim)
274+ }
275+ }
276+ }
277+ })
267 t.Run("OpenBlob", func(t *testing.T) {278 t.Run("OpenBlob", func(t *testing.T) {
268 read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {279 read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {
269 info, rc, err := r.OpenBlob(ctx, repo, rev, path)280 info, rc, err := r.OpenBlob(ctx, repo, rev, path)
@@ -338,6 +349,10 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir
338 _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)349 _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)
339 return err350 return err
340 },351 },
352+ "ListPaths": func(r ports.RepoReader) error {
353+ _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
354+ return err
355+ },
341 "OpenBlob": func(r ports.RepoReader) error {356 "OpenBlob": func(r ports.RepoReader) error {
342 _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})357 _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})
343 return err358 return err
M internal/readertest/conformance.go +108 -0
@@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) {
46 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},46 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
47 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},47 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
48 {"GetTreeIsBounded", testGetTreeIsBounded},48 {"GetTreeIsBounded", testGetTreeIsBounded},
49+ {"ListPaths", testListPaths},
49 } {50 } {
50 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })51 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
51 }52 }
@@ -701,3 +702,110 @@ func testGetTreeIsBounded(t *testing.T, mk Maker) {
701 require.NoError(t, err)702 require.NoError(t, err)
702 assert.False(t, small.Truncated)703 assert.False(t, small.Truncated)
703 }704 }
705+
706+func testListPaths(t *testing.T, mk Maker) {
707+ ctx := context.Background()
708+ g, data := mk(t)
709+ f := buildPaths(t, data)
710+
711+ // git ls-tree -r in order, minus the submodule and the path no address can
712+ // carry.
713+ var want []string
714+ for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") {
715+ meta, p, _ := strings.Cut(rec, "\t")
716+ if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") {
717+ want = append(want, p)
718+ }
719+ }
720+ require.Equal(t, []string{
721+ "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt",
722+ }, want, "the fixture")
723+
724+ got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
725+ require.NoError(t, err)
726+ assert.Equal(t, want, got.Paths)
727+ assert.False(t, got.Truncated)
728+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String())
729+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String())
730+
731+ t.Run("revisions", func(t *testing.T) {
732+ for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} {
733+ list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
734+ require.NoError(t, err, s)
735+ assert.Equal(t, want, list.Paths, s)
736+ }
737+ // The first commit has no submodule: the same files.
738+ first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes)
739+ require.NoError(t, err)
740+ assert.Equal(t, want, first.Paths)
741+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String())
742+ assert.NotEqual(t, got.TreeID, first.TreeID)
743+ })
744+ t.Run("not found", func(t *testing.T) {
745+ for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} {
746+ _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
747+ assert.ErrorIs(t, err, domain.ErrNotFound, s)
748+ }
749+ _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
750+ assert.ErrorIs(t, err, domain.ErrNotFound)
751+ })
752+ t.Run("empty repository", func(t *testing.T) {
753+ e := NewFixture(t, data, "empty-paths")
754+ _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
755+ assert.ErrorIs(t, err, domain.ErrNotFound)
756+ })
757+ t.Run("bounds", func(t *testing.T) {
758+ list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes)
759+ require.NoError(t, err)
760+ assert.Equal(t, want[:3], list.Paths)
761+ assert.True(t, list.Truncated)
762+ assert.Equal(t, got.CommitID, list.CommitID)
763+ assert.Equal(t, got.TreeID, list.TreeID)
764+
765+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes)
766+ require.NoError(t, err)
767+ assert.Equal(t, want, list.Paths)
768+ assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated")
769+
770+ sum := len(want[0]) + len(want[1]) + len(want[2])
771+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1)
772+ require.NoError(t, err)
773+ assert.Equal(t, want[:2], list.Paths)
774+ assert.True(t, list.Truncated)
775+
776+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum)
777+ require.NoError(t, err)
778+ assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit")
779+ assert.True(t, list.Truncated)
780+
781+ total := 0
782+ for _, p := range want {
783+ total += len(p)
784+ }
785+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total)
786+ require.NoError(t, err)
787+ assert.Equal(t, want, list.Paths)
788+ assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated")
789+ })
790+ t.Run("cancelled context", func(t *testing.T) {
791+ ctx, cancel := context.WithCancel(ctx)
792+ cancel()
793+ _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
794+ assert.ErrorIs(t, err, context.Canceled)
795+ })
796+ t.Run("a large tree", func(t *testing.T) {
797+ w := NewFixture(t, data, "paths-wide")
798+ for i := range 300 {
799+ w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x")
800+ }
801+ w.Commit("wide")
802+ order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD"))
803+ list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
804+ require.NoError(t, err)
805+ assert.Equal(t, order, list.Paths)
806+ list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes)
807+ require.NoError(t, err)
808+ assert.Equal(t, order[:100], list.Paths)
809+ assert.True(t, list.Truncated)
810+ })
811+}
M internal/readertest/fixtures.go +18 -0
@@ -63,6 +63,7 @@ func Cases() []Case {
63 {"refs", buildRefs},63 {"refs", buildRefs},
64 {"dangling ref", buildDangling},64 {"dangling ref", buildDangling},
65 {"submodule", buildSubmodule},65 {"submodule", buildSubmodule},
66+ {"paths", buildPaths},
66 {"wide directory", buildWide},67 {"wide directory", buildWide},
67 {"detached HEAD", func(t *testing.T, data string) *Fixture {68 {"detached HEAD", func(t *testing.T, data string) *Fixture {
68 h := NewHistory(t, data)69 h := NewHistory(t, data)
@@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture {
265 return f266 return f
266 }267 }
267 268
269+// buildPaths has what ListPaths must order and skip: nested directories whose
270+// names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a
271+// symlink, a submodule, and a name with a tab that no address can carry. The
272+// tag v1 points at the commit.
273+func buildPaths(t *testing.T, data string) *Fixture {
274+ f := NewFixture(t, data, "paths")
275+ f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n")
276+ f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link")
277+ f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n")
278+ f.Commit("base")
279+ f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod")
280+ f.Git("commit", "--quiet", "-m", "add submodule")
281+ f.Git("tag", "v1")
282+ f.Maintain()
283+ return f
284+}
285+
268 // buildWide has a directory over MaxTreeEntries entries and one over286 // buildWide has a directory over MaxTreeEntries entries and one over
269 // MaxLastCommitEntries, with commits that touch some of them.287 // MaxLastCommitEntries, with commits that touch some of them.
270 func buildWide(t *testing.T, data string) *Fixture {288 func buildWide(t *testing.T, data string) *Fixture {
M internal/usecase/browse.go +9 -0
@@ -203,6 +203,15 @@ func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.Re
203 return tree, nil203 return tree, nil
204 }204 }
205 205
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+
206 // OpenBlob returns the description of a file and a reader of its content. The215 // OpenBlob returns the description of a file and a reader of its content. The
207 // caller closes the reader.216 // caller closes the reader.
208 func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {217 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 {
76 }, func(r *mocks.RepoReader) {76 }, func(r *mocks.RepoReader) {
77 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)77 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
78 }},78 }},
79+ {"ListPaths", func(b *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+ }},
79 {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {85 {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
80 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)86 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
81 if rc != nil {87 if rc != nil {

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 3

Tags 44

↑↓ moveenter openesc close