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.
88 files · +4407 -559
M .forgejo/workflows/ci.yml +5 -1
| @@ -36,8 +36,9 @@ jobs: | |||
| 36 | 36 | steps: | |
| 37 | 37 | # openssh provides ssh-keygen, which the tests use to check that the squash | |
| 38 | 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 | 40 | - name: System packages | |
| 40 | - | run: pacman -Syu --noconfirm --needed git gcc openssh | |
| 41 | + | run: pacman -Syu --noconfirm --needed git gcc openssh nodejs | |
| 41 | 42 | - uses: actions/checkout@v6 | |
| 42 | 43 | # Job containers reach Forgejo through the Gordon edge on the host loopback, | |
| 43 | 44 | # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner). | |
| @@ -66,6 +67,9 @@ jobs: | |||
| 66 | 67 | - run: go test -race ./... | |
| 67 | 68 | - name: Architecture | |
| 68 | 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 | 74 | proto: | |
| 71 | 75 | name: Protobuf and generated code | |
M README.md +1 -1
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 7 | ## Run | |
| 8 | 8 | ||
M go.mod +1 -1
| @@ -12,7 +12,7 @@ tool ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | - | bnema.dev/ermite-api v0.0.0-20261011052945-e9394b717bc2 | |
| 15 | + | bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6 | |
| 16 | 16 | bnema.dev/ermite-git v0.1.0 | |
| 17 | 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 18 | 18 | connectrpc.com/connect v1.21.0 | |
M go.sum +2 -2 generated file: collapsed
M internal/adapters/ermitegit/bench_test.go +4 -0
| @@ -163,6 +163,10 @@ func BenchmarkRepoReader(b *testing.B) { | |||
| 163 | 163 | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true) | |
| 164 | 164 | return | |
| 165 | 165 | }}, | |
| 166 | + | {"ListPaths", func(r ports.RepoReader) (err error) { | |
| 167 | + | _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 168 | + | return | |
| 169 | + | }}, | |
| 166 | 170 | {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }}, | |
| 167 | 171 | {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }}, | |
| 168 | 172 | {"ListCommits/page1", func(r ports.RepoReader) (err error) { | |
M internal/adapters/ermitegit/convert.go +98 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | + | "strings" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| 13 | 14 | git "bnema.dev/ermite-git" | |
| @@ -326,6 +327,103 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain. | |||
| 326 | 327 | return tree, nil | |
| 327 | 328 | } | |
| 328 | 329 | ||
| 330 | + | // pathChunk is the size of the blocks that hold the bytes of the paths. | |
| 331 | + | const pathChunk = 32 << 10 | |
| 332 | + | ||
| 333 | + | // pathWalker lists the files below a tree, depth first, in tree order. | |
| 334 | + | type pathWalker struct { | |
| 335 | + | ctx context.Context | |
| 336 | + | r *git.Repo | |
| 337 | + | maxPaths int | |
| 338 | + | maxBytes int | |
| 339 | + | ||
| 340 | + | // list collects the paths. Their bytes live in chunks: a chunk is filled | |
| 341 | + | // by a strings.Builder, so a path is read back as a string without a copy, | |
| 342 | + | // and a full chunk is left to the paths that point into it instead of | |
| 343 | + | // being grown and copied. | |
| 344 | + | list domain.PathList | |
| 345 | + | chunk strings.Builder | |
| 346 | + | prefix []byte // "dir/sub/" of the directory being read | |
| 347 | + | bad error | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // listPaths lists the files and symbolic links of a revision recursively. | |
| 351 | + | func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |
| 352 | + | commit, root, err := commitTree(r, rev) | |
| 353 | + | if err != nil { | |
| 354 | + | return domain.PathList{}, err | |
| 355 | + | } | |
| 356 | + | treeID, err := domainID(root) | |
| 357 | + | if err != nil { | |
| 358 | + | return domain.PathList{}, err | |
| 359 | + | } | |
| 360 | + | w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes} | |
| 361 | + | w.list.CommitID, w.list.TreeID = commit, treeID | |
| 362 | + | if err := w.walk(root); err != nil { | |
| 363 | + | return domain.PathList{}, err | |
| 364 | + | } | |
| 365 | + | return w.list, nil | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // walk lists one directory. The recursion is inside the callback of ReadTree, | |
| 369 | + | // where the entries of a subdirectory come at the place of the subdirectory, | |
| 370 | + | // like in `git ls-tree -r`; the callback can only say stop, so the reasons to | |
| 371 | + | // stop are kept in the walker. Every ReadTree takes its own pooled buffer, so | |
| 372 | + | // the nesting is safe. | |
| 373 | + | func (w *pathWalker) walk(tree git.ObjectID) error { | |
| 374 | + | if err := w.ctx.Err(); err != nil { | |
| 375 | + | return err | |
| 376 | + | } | |
| 377 | + | err := w.r.ReadTree(tree, func(e git.Entry) bool { | |
| 378 | + | switch { | |
| 379 | + | case e.Mode.IsGitlink(): | |
| 380 | + | return true | |
| 381 | + | case e.Mode.IsTree(): | |
| 382 | + | mark := len(w.prefix) | |
| 383 | + | w.prefix = append(w.prefix, e.Name...) | |
| 384 | + | // The paths below a directory that is not addressable are not | |
| 385 | + | // either: this also bounds the depth of the recursion. | |
| 386 | + | if !domain.IsAddressablePathBytes(w.prefix) { | |
| 387 | + | w.prefix = w.prefix[:mark] | |
| 388 | + | return true | |
| 389 | + | } | |
| 390 | + | w.prefix = append(w.prefix, '/') | |
| 391 | + | err := w.walk(e.ID) | |
| 392 | + | w.prefix = w.prefix[:mark] | |
| 393 | + | if err != nil { | |
| 394 | + | w.bad = err | |
| 395 | + | return false | |
| 396 | + | } | |
| 397 | + | return !w.list.Truncated | |
| 398 | + | case e.Mode.IsRegular(), e.Mode.IsSymlink(): | |
| 399 | + | return w.add(e.Name) | |
| 400 | + | } | |
| 401 | + | w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name) | |
| 402 | + | return false | |
| 403 | + | }) | |
| 404 | + | if err == nil { | |
| 405 | + | err = w.bad | |
| 406 | + | } | |
| 407 | + | return err | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // add lists the file name of the current directory, and says whether the walk | |
| 411 | + | // goes on. PathList.Add filters the paths that are not addressable; their | |
| 412 | + | // bytes stay in the chunk, which is rare enough not to matter. | |
| 413 | + | func (w *pathWalker) add(name []byte) bool { | |
| 414 | + | mark := len(w.prefix) | |
| 415 | + | w.prefix = append(w.prefix, name...) | |
| 416 | + | path := w.prefix | |
| 417 | + | w.prefix = w.prefix[:mark] | |
| 418 | + | if w.chunk.Cap()-w.chunk.Len() < len(path) { | |
| 419 | + | w.chunk = strings.Builder{} | |
| 420 | + | w.chunk.Grow(max(pathChunk, len(path))) | |
| 421 | + | } | |
| 422 | + | start := w.chunk.Len() | |
| 423 | + | w.chunk.Write(path) | |
| 424 | + | return w.list.Add(w.chunk.String()[start:], w.maxPaths, w.maxBytes) | |
| 425 | + | } | |
| 426 | + | ||
| 329 | 427 | // fillLastCommits sets the last commit of the first MaxLastCommitEntries | |
| 330 | 428 | // entries with one walk. | |
| 331 | 429 | // | |
M internal/adapters/ermitegit/ermitegit.go +18 -1
| @@ -98,6 +98,8 @@ type Routes struct { | |||
| 98 | 98 | ListCommits Backend | |
| 99 | 99 | // ListCommitsPath is a page of the history of a file or a directory. | |
| 100 | 100 | ListCommitsPath Backend | |
| 101 | + | // ListPaths is the recursive list of the file paths of a revision. | |
| 102 | + | ListPaths Backend | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 103 | 105 | // tree picks the route of a directory listing. | |
| @@ -115,7 +117,7 @@ func (r Routes) tree(path domain.TreePath, lastCommits bool) Backend { | |||
| 115 | 117 | // AllRoutes returns routes that send every method to b. The tests and the | |
| 116 | 118 | // benchmarks use it to exercise a backend whatever the production routes are. | |
| 117 | 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 | 123 | // Config configures an Adapter. | |
| @@ -331,6 +333,21 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path | |||
| 331 | 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 | 351 | // GetTree lists a directory. | |
| 335 | 352 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | |
| 336 | 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 | 16 | // a method has several shapes that behave differently, each has its own | |
| 17 | 17 | // constant: GetTree, GetTreeLastCommits (root), GetTreeSubdirLastCommits, | |
| 18 | 18 | // 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 | 22 | // Warm is a repository that is open; cold closes it before every call, the | |
| 22 | 23 | // price of the first read after the idle TTL, an eviction or a restart (a | |
| @@ -71,6 +72,10 @@ package ermitegit | |||
| 71 | 72 | // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win | |
| 72 | 73 | // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win | |
| 73 | 74 | // 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 | 79 | // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win | |
| 75 | 80 | // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba | |
| 76 | 81 | // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba | |
| @@ -126,6 +131,11 @@ package ermitegit | |||
| 126 | 131 | // - OpenBlob by size: below 1 MiB the small blob loses warm bytes and | |
| 127 | 132 | // allocations on lazygit; above, it loses time (inflate speed: 52 ms | |
| 128 | 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 | 139 | func DefaultRoutes() Routes { | |
| 130 | 140 | return Routes{ | |
| 131 | 141 | ListRefs: ErmiteGit, | |
| @@ -138,5 +148,6 @@ func DefaultRoutes() Routes { | |||
| 138 | 148 | OpenBlob: CLI, | |
| 139 | 149 | ListCommits: ErmiteGit, | |
| 140 | 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 | 288 | return obj, err | |
| 289 | 289 | } | |
| 290 | 290 | ||
| 291 | + | // ListPaths lists the files and symbolic links of a revision with | |
| 292 | + | // `git ls-tree -r -z`, in its order, up to the bounds of the port. | |
| 293 | + | // | |
| 294 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| 295 | + | func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |
| 296 | + | dir, err := g.repoDir(repo) | |
| 297 | + | if err != nil { | |
| 298 | + | return domain.PathList{}, err | |
| 299 | + | } | |
| 300 | + | obj, err := g.lookup(ctx, dir, rev, domain.TreePath{}) | |
| 301 | + | if err != nil { | |
| 302 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |
| 303 | + | } | |
| 304 | + | treeID, err := domain.ParseObjectID(obj.info.ID) | |
| 305 | + | if err != nil { | |
| 306 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |
| 307 | + | } | |
| 308 | + | list := domain.PathList{CommitID: obj.commit, TreeID: treeID} | |
| 309 | + | err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error { | |
| 310 | + | for { | |
| 311 | + | tok, err := readToken(r) | |
| 312 | + | if errors.Is(err, io.EOF) { | |
| 313 | + | return nil | |
| 314 | + | } | |
| 315 | + | if err != nil { | |
| 316 | + | return err | |
| 317 | + | } | |
| 318 | + | meta, path, ok := strings.Cut(tok, "\t") | |
| 319 | + | if !ok { | |
| 320 | + | return fmt.Errorf("malformed ls-tree entry %q", tok) | |
| 321 | + | } | |
| 322 | + | // meta is "<mode> <type> <id>": a submodule is a commit. | |
| 323 | + | _, rest, _ := strings.Cut(meta, " ") | |
| 324 | + | if typ, _, _ := strings.Cut(rest, " "); typ == "commit" { | |
| 325 | + | continue | |
| 326 | + | } | |
| 327 | + | // A copy: path would keep the whole "<mode> <type> <id>\t" entry alive. | |
| 328 | + | if !list.Add(strings.Clone(path), maxPaths, maxBytes) { | |
| 329 | + | return errStop | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | }) | |
| 333 | + | if err != nil { | |
| 334 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |
| 335 | + | } | |
| 336 | + | return list, nil | |
| 337 | + | } | |
| 338 | + | ||
| 291 | 339 | // GetTree lists a directory with `git ls-tree -z -l`. | |
| 292 | 340 | // | |
| 293 | 341 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
M internal/adapters/rpc/api.go +1 -1
| @@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20 | |||
| 26 | 26 | ||
| 27 | 27 | // Features are the optional capabilities of the contract that this server | |
| 28 | 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 | 31 | // Config configures the API. | |
| 32 | 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 | 85 | return connect.NewResponse(out), nil | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | + | // ListPaths lists the paths of every file of a revision. | |
| 89 | + | func (s *gitService) ListPaths(ctx context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | |
| 90 | + | name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision()) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, connectError(ctx, err) | |
| 93 | + | } | |
| 94 | + | list, err := s.browse.ListPaths(ctx, caller(ctx), name, rev) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, connectError(ctx, err) | |
| 97 | + | } | |
| 98 | + | return connect.NewResponse(&pb.ListPathsResponse{ | |
| 99 | + | CommitId: list.CommitID.String(), | |
| 100 | + | TreeId: list.TreeID.String(), | |
| 101 | + | Paths: list.Paths, | |
| 102 | + | Truncated: list.Truncated, | |
| 103 | + | }), nil | |
| 104 | + | } | |
| 105 | + | ||
| 88 | 106 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | |
| 89 | 107 | // carries the metadata and the first chunk. | |
| 90 | 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 | 3 | package rpc | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 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 | 104 | func TestGitServiceListRefs(t *testing.T) { | |
| 54 | 105 | t.Parallel() | |
| 55 | 106 | tests := []struct { | |
M internal/adapters/web/ASSETS.md +1 -0
| @@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 9 | 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | |
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | 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 | 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 | 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 | 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 | 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 24 | 24 | // the content, so they can be cached for ever. | |
| 25 | 25 | type Assets struct { | |
| 26 | - | CSS, HTMX, Live, SSE, App, Icon string | |
| 26 | + | CSS, HTMX, Live, SSE, App, Files, Icon string | |
| 27 | 27 | // Font is the UI font, preloaded by every page. | |
| 28 | 28 | Font string | |
| 29 | 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | |
| @@ -55,6 +55,7 @@ var scripts = []struct { | |||
| 55 | 55 | {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }}, | |
| 56 | 56 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | |
| 57 | 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 | 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 | 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 | 192 | pinRef(&l, t) | |
| 190 | 193 | l.Crumbs = pathCrumbs(d.name(), "blob", t) | |
| 191 | 194 | 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 | 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 | 17 | k-esc from:body -> ermite.closeDialogs(); | |
| 18 | 18 | k-help from:body -> ermite.dialog('panel-help'); | |
| 19 | 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 | 21 | k-filter from:body -> ermite.focusFilter(); | |
| 22 | 22 | k-gh from:body -> ermite.go('.brand'); | |
| 23 | 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 | 14 | <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div> | |
| 15 | 15 | <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div> | |
| 16 | 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 | 18 | <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div> | |
| 19 | 19 | <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches & tags, merge requests</dd></div> | |
| 20 | 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 | 86 | // OverlayCmd is the ":" command line: one filterable list mixing sections, | |
| 87 | - | // refs, files of this directory and repos. | |
| 87 | + | // refs and repos. | |
| 88 | 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 | 90 | <div class="panel-head"> | |
| 91 | 91 | <label for="cmd-q" class="sr">go to</label> | |
| 92 | 92 | <input | |
| @@ -96,7 +96,7 @@ templ OverlayCmd(items []CmdItem) { | |||
| 96 | 96 | autocomplete="off" | |
| 97 | 97 | spellcheck="false" | |
| 98 | 98 | aria-controls="cmd-list" | |
| 99 | - | hx-on:input="ermite.cmdFilter(this)" | |
| 99 | + | hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" | |
| 100 | 100 | hx-on:keydown="ermite.cmdKey(event)" | |
| 101 | 101 | /> | |
| 102 | 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
| @@ -34,7 +34,7 @@ func OverlayHelp() templ.Component { | |||
| 34 | 34 | templ_7745c5c3_Var1 = templ.NopComponent | |
| 35 | 35 | } | |
| 36 | 36 | ctx = templ.ClearChildren(ctx) | |
| 37 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<dialog id=\"panel-help\" class=\"panel modal\" aria-label=\"Keyboard shortcuts\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><h2>Keyboard shortcuts</h2><button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd> close</button></div><dl class=\"keys\"><div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div><div><dt><kbd>?</kbd></dt><dd>this list</dd></div><div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div><div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div><div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div><div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div><div><dt><kbd>b</kbd></dt><dd>") | |
| 37 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<dialog id=\"panel-help\" class=\"panel modal\" aria-label=\"Keyboard shortcuts\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><h2>Keyboard shortcuts</h2><button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd> close</button></div><dl class=\"keys\"><div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div><div><dt><kbd>?</kbd></dt><dd>this list</dd></div><div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div><div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div><div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div><div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div><div><dt><kbd>b</kbd></dt><dd>") | |
| 38 | 38 | if templ_7745c5c3_Err != nil { | |
| 39 | 39 | return templ_7745c5c3_Err | |
| 40 | 40 | } | |
| @@ -214,7 +214,7 @@ func refItem(r RefItem) templ.Component { | |||
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | 216 | // OverlayCmd is the ":" command line: one filterable list mixing sections, | |
| 217 | - | // refs, files of this directory and repos. | |
| 217 | + | // refs and repos. | |
| 218 | 218 | func OverlayCmd(items []CmdItem) templ.Component { | |
| 219 | 219 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 220 | 220 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| @@ -236,7 +236,7 @@ func OverlayCmd(items []CmdItem) templ.Component { | |||
| 236 | 236 | templ_7745c5c3_Var9 = templ.NopComponent | |
| 237 | 237 | } | |
| 238 | 238 | ctx = templ.ClearChildren(ctx) | |
| 239 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"ermite.cmdFilter(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">") | |
| 239 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">") | |
| 240 | 240 | if templ_7745c5c3_Err != nil { | |
| 241 | 241 | return templ_7745c5c3_Err | |
| 242 | 242 | } | |
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 | 77 | // id of the blob at a path: a tree listing of its directory costs more | |
| 78 | 78 | // than opening a small file. | |
| 79 | 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 | 81 | w.WriteHeader(http.StatusNotModified) | |
| 82 | 82 | return | |
| 83 | 83 | } | |
M internal/adapters/web/raw_test.go +4 -1
| @@ -61,7 +61,10 @@ func TestRawRevalidation(t *testing.T) { | |||
| 61 | 61 | {"no validator", "/r/raw/main/docs/a.txt", http.MethodGet, "", http.StatusOK, "hello"}, | |
| 62 | 62 | {"matching validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotModified, ""}, | |
| 63 | 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 | 68 | {"head", "/r/raw/main/docs/a.txt", http.MethodHead, "", http.StatusOK, ""}, | |
| 66 | 69 | {"missing file", "/r/raw/main/docs/none.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""}, | |
| 67 | 70 | {"directory", "/r/raw/main", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""}, | |
M internal/adapters/web/refs.go +45 -1
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "net/url" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| @@ -79,6 +80,34 @@ type target struct { | |||
| 79 | 80 | path string | |
| 80 | 81 | // detached is true when the URL names a commit and not a ref. | |
| 81 | 82 | detached bool | |
| 83 | + | // commit is the full id of the commit when the page has learnt it from | |
| 84 | + | // the API and rev is an abbreviation; "" otherwise. | |
| 85 | + | commit string | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // commitID is the full id of the commit of the target when it is known, and | |
| 89 | + | // rev otherwise. | |
| 90 | + | func (t target) commitID() string { | |
| 91 | + | if t.commit != "" { | |
| 92 | + | return t.commit | |
| 93 | + | } | |
| 94 | + | return t.rev | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // resolveCommit sets the full id of a target named by an abbreviated commit id, | |
| 98 | + | // with one ResolveRevision. The page renders without it (it only lacks the file | |
| 99 | + | // finder), so a failure is not one. | |
| 100 | + | // | |
| 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 | 113 | // 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 | 164 | // pinRef points the ref switcher of the layout at what a page shows: a | |
| 136 | 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 | 167 | func pinRef(l *Layout, t target) { | |
| 139 | 168 | if t.detached { | |
| 140 | 169 | l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), "" | |
| 141 | 170 | if len(t.rev) == 40 { | |
| 142 | 171 | l.SHA = t.rev | |
| 143 | 172 | } | |
| 173 | + | l.setFinder(t.commitID(), t.rev) | |
| 144 | 174 | for i := range l.Heads { | |
| 145 | 175 | l.Heads[i].Current = false | |
| 146 | 176 | } | |
| @@ -150,6 +180,7 @@ func pinRef(l *Layout, t target) { | |||
| 150 | 180 | return | |
| 151 | 181 | } | |
| 152 | 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 | 184 | for i := range l.Heads { | |
| 154 | 185 | 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 | 205 | func hasName(items []RefItem, name string) bool { | |
| 162 | 206 | for _, it := range items { | |
| 163 | 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 | 121 | def = d.head.ShortName | |
| 122 | 122 | l.Ref, l.RefPath = def, def | |
| 123 | 123 | l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String()) | |
| 124 | + | l.setFinder(l.SHA, def) | |
| 124 | 125 | } | |
| 125 | 126 | mk := func(key, label, href, id string) Section { | |
| 126 | 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 | 21 | <script src={ l.Assets.Live } defer></script> | |
| 22 | 22 | <script src={ l.Assets.SSE } defer></script> | |
| 23 | 23 | <script src={ l.Assets.App } defer></script> | |
| 24 | + | <script src={ l.Assets.Files } defer></script> | |
| 24 | 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 | 36 | <a class="skip" href="#main">skip to content</a> | |
| 27 | 37 | @Header(l) | |
| 28 | 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 | 54 | & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); } | |
| 55 | 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 | 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 | 62 | .keys { | |
M internal/adapters/web/static/css/30-list.css +10 -1
| @@ -38,7 +38,10 @@ | |||
| 38 | 38 | } | |
| 39 | 39 | .list.tree { | |
| 40 | 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 | 45 | & .c-size { width: 90px; text-align: right; } | |
| 43 | 46 | & .c-when { width: 100px; } | |
| 44 | 47 | & .c-sub { width: auto; } | |
| @@ -71,6 +74,12 @@ | |||
| 71 | 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 | 83 | /* section headings inside a page */ | |
| 75 | 84 | .rule-h { | |
| 76 | 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 | 110 | // moving the cursor, and returns the rows. | |
| 111 | 111 | hide(input) { | |
| 112 | 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 | 118 | for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t); | |
| 115 | 119 | if (input.dataset.groups) { | |
| 116 | 120 | for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); | |
| @@ -120,10 +124,12 @@ | |||
| 120 | 124 | // The "go to" palette. | |
| 121 | 125 | cmdFilter(input) { | |
| 122 | 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 | 130 | for (const li of items) li.hidden = !li.dataset.k.includes(t); | |
| 125 | 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 | 134 | cmdKey(e) { | |
| 129 | 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 | 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 | 14 | // TreeRow is one entry of a directory. | |
| 18 | 15 | type TreeRow struct { | |
| 19 | - | Glyph string // ▾ directory, @ symbolic link, + submodule, · file | |
| 20 | - | Name string | |
| 21 | - | Href string // "" for a submodule | |
| 16 | + | Glyph string // ▸ directory, @ symbolic link, + submodule, · file | |
| 17 | + | Name string | |
| 18 | + | Href string // "" for a submodule | |
| 19 | + | // Expand is the URL of the rows of a directory (Href?rows); "" for any | |
| 20 | + | // other entry. | |
| 21 | + | Expand string | |
| 22 | 22 | Size string | |
| 23 | 23 | Subject string | |
| 24 | 24 | CHref string | |
| @@ -34,6 +34,9 @@ type TreePage struct { | |||
| 34 | 34 | Readme *Readme | |
| 35 | 35 | // Notes say what the listing leaves out. | |
| 36 | 36 | 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 | 42 | // 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 | 59 | entries := res.Entries | |
| 57 | 60 | now := s.cfg.Now() | |
| 58 | 61 | ||
| 62 | + | rowsOnly := r.URL.Query().Has("rows") | |
| 59 | 63 | p := TreePage{} | |
| 60 | - | l := s.repoLayout(ctx, d, "tree") | |
| 61 | 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 | 66 | switch e.Kind { | |
| 64 | 67 | case domain.EntryDirectory: | |
| 65 | - | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | |
| 68 | + | row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-" | |
| 66 | 69 | row.Href = repoHref(d.name(), "tree", t.name, e.Path) | |
| 70 | + | row.Expand = row.Href + "?rows" | |
| 67 | 71 | case domain.EntrySymlink: | |
| 68 | 72 | row.Glyph = "@" | |
| 69 | 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 | 75 | row.Glyph, row.Size = "+", "-" | |
| 72 | 76 | default: | |
| 73 | 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 | 79 | if c := e.LastCommit; c != nil { | |
| 79 | 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 | 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 | 92 | if res.Truncated { | |
| 85 | 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 | 103 | 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 | 110 | pinRef(&l, t) | |
| 99 | 111 | l.Crumbs = pathCrumbs(d.name(), "tree", t) | |
| 100 | 112 | if t.path == "" { | |
M internal/adapters/web/tree.templ +58 -28
| @@ -2,52 +2,82 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | - | // Tree is a directory: the filter, the entries with their last commit, the | |
| 5 | + | // Tree is a directory: the file finder, the entries with their last commit, the | |
| 6 | 6 | // notes on what is left out, and the README of the directory below. | |
| 7 | 7 | templ Tree(l Layout, p TreePage) { | |
| 8 | 8 | @Shell(l, nil) { | |
| 9 | 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 | 11 | <input | |
| 12 | - | id="tree-filter" | |
| 12 | + | id="finder-q" | |
| 13 | 13 | type="search" | |
| 14 | - | data-filter="#tree-rows > tr" | |
| 15 | - | placeholder="Filter this directory…" | |
| 14 | + | data-filter | |
| 15 | + | role="combobox" | |
| 16 | + | aria-autocomplete="list" | |
| 17 | + | aria-controls="finder-list" | |
| 18 | + | aria-expanded="false" | |
| 19 | + | placeholder="Find a file…" | |
| 16 | 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 | 26 | </div> | |
| 20 | - | <table class="list tree" aria-label="Entries"> | |
| 21 | - | <thead> | |
| 22 | - | <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr> | |
| 23 | - | </thead> | |
| 24 | - | <tbody id="tree-rows" data-rows> | |
| 25 | - | for _, r := range p.Rows { | |
| 26 | - | @TreeRowView(r) | |
| 27 | - | } | |
| 28 | - | </tbody> | |
| 29 | - | </table> | |
| 30 | - | if len(p.Rows) == 0 { | |
| 31 | - | <p class="lede dim">this directory is empty.</p> | |
| 32 | - | } | |
| 33 | - | for _, n := range p.Notes { | |
| 34 | - | <p class="note">{ n }</p> | |
| 35 | - | } | |
| 36 | - | if p.Readme != nil { | |
| 37 | - | @ReadmeCard(*p.Readme) | |
| 38 | - | } | |
| 27 | + | <div id="finder" hidden> | |
| 28 | + | <p id="finder-status" class="dim" aria-live="polite"></p> | |
| 29 | + | <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul> | |
| 30 | + | </div> | |
| 31 | + | <div id="tree-view"> | |
| 32 | + | <table class="list tree" aria-label="Entries"> | |
| 33 | + | <thead> | |
| 34 | + | <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr> | |
| 35 | + | </thead> | |
| 36 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> | |
| 37 | + | @TreeRows(p) | |
| 38 | + | </tbody> | |
| 39 | + | </table> | |
| 40 | + | if len(p.Rows) == 0 { | |
| 41 | + | <p class="lede dim">this directory is empty.</p> | |
| 42 | + | } | |
| 43 | + | for _, n := range p.Notes { | |
| 44 | + | <p class="note">{ n }</p> | |
| 45 | + | } | |
| 46 | + | if p.Readme != nil { | |
| 47 | + | @ReadmeCard(*p.Readme) | |
| 48 | + | } | |
| 49 | + | </div> | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // TreeRows is the rows of a directory alone: what files.js inserts under a | |
| 54 | + | // directory it opens in place. When the directory has more entries than are | |
| 55 | + | // listed, the last row links to its page. | |
| 56 | + | templ TreeRows(p TreePage) { | |
| 57 | + | for _, r := range p.Rows { | |
| 58 | + | @TreeRowView(r) | |
| 59 | + | } | |
| 60 | + | if p.More != "" { | |
| 61 | + | <tr class="tree-more"><td colspan="7"><a href={ templ.SafeURL(p.More) }>more entries: open the directory</a></td></tr> | |
| 39 | 62 | } | |
| 40 | 63 | } | |
| 41 | 64 | ||
| 42 | - | // TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic | |
| 43 | - | // link, + submodule, · file. | |
| 65 | + | // TreeRowView is one entry of a directory. Glyph: ▸ directory (▾ once opened | |
| 66 | + | // in place), @ symbolic link, + submodule, · file. A directory link carries | |
| 67 | + | // data-expand, the URL of its rows. | |
| 44 | 68 | templ TreeRowView(r TreeRow) { | |
| 45 | 69 | <tr data-cursor?={ r.Cursor }> | |
| 46 | 70 | <td class="cur" aria-hidden="true"></td> | |
| 47 | 71 | <td class="c-kind" aria-hidden="true">{ r.Glyph }</td> | |
| 48 | 72 | <td class="c-name"> | |
| 49 | 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 | 81 | } else { | |
| 52 | 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 | 56 | Live bool | |
| 57 | 57 | // LiveURL is the stream the page follows, with its query. | |
| 58 | 58 | LiveURL string | |
| 59 | + | // PathsURL is the list of the file paths of the commit the page shows | |
| 60 | + | // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with; | |
| 61 | + | // both are "" when the page has no commit to search. | |
| 62 | + | PathsURL, BlobBase string | |
| 59 | 63 | } | |
| 60 | 64 | ||
| 61 | 65 | // Link is a plain label + href pair. | |
M internal/adapters/web/web.go +6 -2
| @@ -88,7 +88,9 @@ type Server struct { | |||
| 88 | 88 | origin string // scheme://host of the public URL, what a POST must come from | |
| 89 | 89 | assets *assetSet | |
| 90 | 90 | md *markdown | |
| 91 | - | mux *http.ServeMux | |
| 91 | + | // pathCache holds the path lists the file finder fetches. | |
| 92 | + | pathCache *pathCache | |
| 93 | + | mux *http.ServeMux | |
| 92 | 94 | // refsFan shares the fragments of the refs page between the watchers of | |
| 93 | 95 | // a push; entries are few and short-lived. | |
| 94 | 96 | refsFan fanout | |
| @@ -115,7 +117,8 @@ func New(cfg Config) (*Server, error) { | |||
| 115 | 117 | } | |
| 116 | 118 | s := &Server{ | |
| 117 | 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 | 123 | s.mux = s.routes() | |
| 121 | 124 | return s, nil | |
| @@ -143,6 +146,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 143 | 146 | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | |
| 144 | 147 | mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob)) | |
| 145 | 148 | mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw) | |
| 149 | + | mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths) | |
| 146 | 150 | if s.cfg.MergeRequests != nil { | |
| 147 | 151 | mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList)) | |
| 148 | 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 | 68 | // every principal in a stable order. | |
| 69 | 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 | 105 | // TestAC2ReadAccess: every read RPC on a public repository, a private one and | |
| 72 | 106 | // one inside the limited token's list. | |
| 73 | 107 | func TestAC2ReadAccess(t *testing.T) { | |
| @@ -90,6 +124,10 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 90 | 124 | _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo})) | |
| 91 | 125 | return err | |
| 92 | 126 | }, | |
| 127 | + | "ListPaths": func(c *clients, repo string) error { | |
| 128 | + | _, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo})) | |
| 129 | + | return err | |
| 130 | + | }, | |
| 93 | 131 | "GetBlob": func(c *clients, repo string) error { | |
| 94 | 132 | _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"}) | |
| 95 | 133 | return err | |
| @@ -158,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 158 | 196 | for _, who := range allPrincipals { | |
| 159 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 160 | 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 | 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 | 324 | ver := decode[versionOut](t, res.stdout) | |
| 325 | 325 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 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 | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 329 | 329 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 330 | require.Equal(t, 0, code, stderr) | |
M internal/app/testdata/web/blob-binary.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -174,7 +176,7 @@ | |||
| 174 | 176 | <dt> | |
| 175 | 177 | <kbd>/</kbd> | |
| 176 | 178 | </dt> | |
| 177 | - | <dd>filter the list</dd> | |
| 179 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 178 | 180 | </div> | |
| 179 | 181 | <div> | |
| 180 | 182 | <dt> | |
| @@ -261,10 +263,10 @@ | |||
| 261 | 263 | </section> | |
| 262 | 264 | </div> | |
| 263 | 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 | 267 | <div class="panel-head"> | |
| 266 | 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 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 269 | 271 | <kbd>esc</kbd> | |
| 270 | 272 | </button> | |
| @@ -343,7 +345,7 @@ | |||
| 343 | 345 | k-esc from:body -> ermite.closeDialogs(); | |
| 344 | 346 | k-help from:body -> ermite.dialog('panel-help'); | |
| 345 | 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 | 349 | k-filter from:body -> ermite.focusFilter(); | |
| 348 | 350 | k-gh from:body -> ermite.go('.brand'); | |
| 349 | 351 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-code.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -244,7 +246,7 @@ | |||
| 244 | 246 | <dt> | |
| 245 | 247 | <kbd>/</kbd> | |
| 246 | 248 | </dt> | |
| 247 | - | <dd>filter the list</dd> | |
| 249 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 248 | 250 | </div> | |
| 249 | 251 | <div> | |
| 250 | 252 | <dt> | |
| @@ -331,10 +333,10 @@ | |||
| 331 | 333 | </section> | |
| 332 | 334 | </div> | |
| 333 | 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 | 337 | <div class="panel-head"> | |
| 336 | 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 | 340 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 339 | 341 | <kbd>esc</kbd> | |
| 340 | 342 | </button> | |
| @@ -413,7 +415,7 @@ | |||
| 413 | 415 | k-esc from:body -> ermite.closeDialogs(); | |
| 414 | 416 | k-help from:body -> ermite.dialog('panel-help'); | |
| 415 | 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 | 419 | k-filter from:body -> ermite.focusFilter(); | |
| 418 | 420 | k-gh from:body -> ermite.go('.brand'); | |
| 419 | 421 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-empty.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -174,7 +176,7 @@ | |||
| 174 | 176 | <dt> | |
| 175 | 177 | <kbd>/</kbd> | |
| 176 | 178 | </dt> | |
| 177 | - | <dd>filter the list</dd> | |
| 179 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 178 | 180 | </div> | |
| 179 | 181 | <div> | |
| 180 | 182 | <dt> | |
| @@ -261,10 +263,10 @@ | |||
| 261 | 263 | </section> | |
| 262 | 264 | </div> | |
| 263 | 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 | 267 | <div class="panel-head"> | |
| 266 | 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 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 269 | 271 | <kbd>esc</kbd> | |
| 270 | 272 | </button> | |
| @@ -343,7 +345,7 @@ | |||
| 343 | 345 | k-esc from:body -> ermite.closeDialogs(); | |
| 344 | 346 | k-help from:body -> ermite.dialog('panel-help'); | |
| 345 | 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 | 349 | k-filter from:body -> ermite.focusFilter(); | |
| 348 | 350 | k-gh from:body -> ermite.go('.brand'); | |
| 349 | 351 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-image.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -176,7 +178,7 @@ | |||
| 176 | 178 | <dt> | |
| 177 | 179 | <kbd>/</kbd> | |
| 178 | 180 | </dt> | |
| 179 | - | <dd>filter the list</dd> | |
| 181 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 180 | 182 | </div> | |
| 181 | 183 | <div> | |
| 182 | 184 | <dt> | |
| @@ -263,10 +265,10 @@ | |||
| 263 | 265 | </section> | |
| 264 | 266 | </div> | |
| 265 | 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 | 269 | <div class="panel-head"> | |
| 268 | 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 | 272 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 271 | 273 | <kbd>esc</kbd> | |
| 272 | 274 | </button> | |
| @@ -345,7 +347,7 @@ | |||
| 345 | 347 | k-esc from:body -> ermite.closeDialogs(); | |
| 346 | 348 | k-help from:body -> ermite.dialog('panel-help'); | |
| 347 | 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 | 351 | k-filter from:body -> ermite.focusFilter(); | |
| 350 | 352 | k-gh from:body -> ermite.go('.brand'); | |
| 351 | 353 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-markdown.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -241,7 +243,7 @@ | |||
| 241 | 243 | <dt> | |
| 242 | 244 | <kbd>/</kbd> | |
| 243 | 245 | </dt> | |
| 244 | - | <dd>filter the list</dd> | |
| 246 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 245 | 247 | </div> | |
| 246 | 248 | <div> | |
| 247 | 249 | <dt> | |
| @@ -328,10 +330,10 @@ | |||
| 328 | 330 | </section> | |
| 329 | 331 | </div> | |
| 330 | 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 | 334 | <div class="panel-head"> | |
| 333 | 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 | 337 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 336 | 338 | <kbd>esc</kbd> | |
| 337 | 339 | </button> | |
| @@ -410,7 +412,7 @@ | |||
| 410 | 412 | k-esc from:body -> ermite.closeDialogs(); | |
| 411 | 413 | k-help from:body -> ermite.dialog('panel-help'); | |
| 412 | 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 | 416 | k-filter from:body -> ermite.focusFilter(); | |
| 415 | 417 | k-gh from:body -> ermite.go('.brand'); | |
| 416 | 418 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-svg.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -199,7 +201,7 @@ | |||
| 199 | 201 | <dt> | |
| 200 | 202 | <kbd>/</kbd> | |
| 201 | 203 | </dt> | |
| 202 | - | <dd>filter the list</dd> | |
| 204 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 203 | 205 | </div> | |
| 204 | 206 | <div> | |
| 205 | 207 | <dt> | |
| @@ -286,10 +288,10 @@ | |||
| 286 | 288 | </section> | |
| 287 | 289 | </div> | |
| 288 | 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 | 292 | <div class="panel-head"> | |
| 291 | 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 | 295 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 294 | 296 | <kbd>esc</kbd> | |
| 295 | 297 | </button> | |
| @@ -368,7 +370,7 @@ | |||
| 368 | 370 | k-esc from:body -> ermite.closeDialogs(); | |
| 369 | 371 | k-help from:body -> ermite.dialog('panel-help'); | |
| 370 | 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 | 374 | k-filter from:body -> ermite.focusFilter(); | |
| 373 | 375 | k-gh from:body -> ermite.go('.brand'); | |
| 374 | 376 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-text.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -194,7 +196,7 @@ | |||
| 194 | 196 | <dt> | |
| 195 | 197 | <kbd>/</kbd> | |
| 196 | 198 | </dt> | |
| 197 | - | <dd>filter the list</dd> | |
| 199 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 198 | 200 | </div> | |
| 199 | 201 | <div> | |
| 200 | 202 | <dt> | |
| @@ -281,10 +283,10 @@ | |||
| 281 | 283 | </section> | |
| 282 | 284 | </div> | |
| 283 | 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 | 287 | <div class="panel-head"> | |
| 286 | 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 | 290 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 289 | 291 | <kbd>esc</kbd> | |
| 290 | 292 | </button> | |
| @@ -363,7 +365,7 @@ | |||
| 363 | 365 | k-esc from:body -> ermite.closeDialogs(); | |
| 364 | 366 | k-help from:body -> ermite.dialog('panel-help'); | |
| 365 | 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 | 369 | k-filter from:body -> ermite.focusFilter(); | |
| 368 | 370 | k-gh from:body -> ermite.go('.brand'); | |
| 369 | 371 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-too-large.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -174,7 +176,7 @@ | |||
| 174 | 176 | <dt> | |
| 175 | 177 | <kbd>/</kbd> | |
| 176 | 178 | </dt> | |
| 177 | - | <dd>filter the list</dd> | |
| 179 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 178 | 180 | </div> | |
| 179 | 181 | <div> | |
| 180 | 182 | <dt> | |
| @@ -261,10 +263,10 @@ | |||
| 261 | 263 | </section> | |
| 262 | 264 | </div> | |
| 263 | 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 | 267 | <div class="panel-head"> | |
| 266 | 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 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 269 | 271 | <kbd>esc</kbd> | |
| 270 | 272 | </button> | |
| @@ -343,7 +345,7 @@ | |||
| 343 | 345 | k-esc from:body -> ermite.closeDialogs(); | |
| 344 | 346 | k-help from:body -> ermite.dialog('panel-help'); | |
| 345 | 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 | 349 | k-filter from:body -> ermite.focusFilter(); | |
| 348 | 350 | k-gh from:body -> ermite.go('.brand'); | |
| 349 | 351 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/blob-trojan.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -241,7 +243,7 @@ | |||
| 241 | 243 | <dt> | |
| 242 | 244 | <kbd>/</kbd> | |
| 243 | 245 | </dt> | |
| 244 | - | <dd>filter the list</dd> | |
| 246 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 245 | 247 | </div> | |
| 246 | 248 | <div> | |
| 247 | 249 | <dt> | |
| @@ -328,10 +330,10 @@ | |||
| 328 | 330 | </section> | |
| 329 | 331 | </div> | |
| 330 | 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 | 334 | <div class="panel-head"> | |
| 333 | 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 | 337 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 336 | 338 | <kbd>esc</kbd> | |
| 337 | 339 | </button> | |
| @@ -410,7 +412,7 @@ | |||
| 410 | 412 | k-esc from:body -> ermite.closeDialogs(); | |
| 411 | 413 | k-help from:body -> ermite.dialog('panel-help'); | |
| 412 | 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 | 416 | k-filter from:body -> ermite.focusFilter(); | |
| 415 | 417 | k-gh from:body -> ermite.go('.brand'); | |
| 416 | 418 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-big.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -211,7 +213,7 @@ | |||
| 211 | 213 | <dt> | |
| 212 | 214 | <kbd>/</kbd> | |
| 213 | 215 | </dt> | |
| 214 | - | <dd>filter the list</dd> | |
| 216 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 215 | 217 | </div> | |
| 216 | 218 | <div> | |
| 217 | 219 | <dt> | |
| @@ -298,10 +300,10 @@ | |||
| 298 | 300 | </section> | |
| 299 | 301 | </div> | |
| 300 | 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 | 304 | <div class="panel-head"> | |
| 303 | 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 | 307 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 306 | 308 | <kbd>esc</kbd> | |
| 307 | 309 | </button> | |
| @@ -380,7 +382,7 @@ | |||
| 380 | 382 | k-esc from:body -> ermite.closeDialogs(); | |
| 381 | 383 | k-help from:body -> ermite.dialog('panel-help'); | |
| 382 | 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 | 386 | k-filter from:body -> ermite.focusFilter(); | |
| 385 | 387 | k-gh from:body -> ermite.go('.brand'); | |
| 386 | 388 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-binary.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -206,7 +208,7 @@ | |||
| 206 | 208 | <dt> | |
| 207 | 209 | <kbd>/</kbd> | |
| 208 | 210 | </dt> | |
| 209 | - | <dd>filter the list</dd> | |
| 211 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 210 | 212 | </div> | |
| 211 | 213 | <div> | |
| 212 | 214 | <dt> | |
| @@ -293,10 +295,10 @@ | |||
| 293 | 295 | </section> | |
| 294 | 296 | </div> | |
| 295 | 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 | 299 | <div class="panel-head"> | |
| 298 | 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 | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 301 | 303 | <kbd>esc</kbd> | |
| 302 | 304 | </button> | |
| @@ -375,7 +377,7 @@ | |||
| 375 | 377 | k-esc from:body -> ermite.closeDialogs(); | |
| 376 | 378 | k-help from:body -> ermite.dialog('panel-help'); | |
| 377 | 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 | 381 | k-filter from:body -> ermite.focusFilter(); | |
| 380 | 382 | k-gh from:body -> ermite.go('.brand'); | |
| 381 | 383 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-merge.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -245,7 +247,7 @@ | |||
| 245 | 247 | <dt> | |
| 246 | 248 | <kbd>/</kbd> | |
| 247 | 249 | </dt> | |
| 248 | - | <dd>filter the list</dd> | |
| 250 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 249 | 251 | </div> | |
| 250 | 252 | <div> | |
| 251 | 253 | <dt> | |
| @@ -332,10 +334,10 @@ | |||
| 332 | 334 | </section> | |
| 333 | 335 | </div> | |
| 334 | 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 | 338 | <div class="panel-head"> | |
| 337 | 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 | 341 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 340 | 342 | <kbd>esc</kbd> | |
| 341 | 343 | </button> | |
| @@ -414,7 +416,7 @@ | |||
| 414 | 416 | k-esc from:body -> ermite.closeDialogs(); | |
| 415 | 417 | k-help from:body -> ermite.dialog('panel-help'); | |
| 416 | 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 | 420 | k-filter from:body -> ermite.focusFilter(); | |
| 419 | 421 | k-gh from:body -> ermite.go('.brand'); | |
| 420 | 422 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-modify-split.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -366,7 +368,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 366 | 368 | <dt> | |
| 367 | 369 | <kbd>/</kbd> | |
| 368 | 370 | </dt> | |
| 369 | - | <dd>filter the list</dd> | |
| 371 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 370 | 372 | </div> | |
| 371 | 373 | <div> | |
| 372 | 374 | <dt> | |
| @@ -453,10 +455,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 453 | 455 | </section> | |
| 454 | 456 | </div> | |
| 455 | 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 | 459 | <div class="panel-head"> | |
| 458 | 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 | 462 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 461 | 463 | <kbd>esc</kbd> | |
| 462 | 464 | </button> | |
| @@ -535,7 +537,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 535 | 537 | k-esc from:body -> ermite.closeDialogs(); | |
| 536 | 538 | k-help from:body -> ermite.dialog('panel-help'); | |
| 537 | 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 | 541 | k-filter from:body -> ermite.focusFilter(); | |
| 540 | 542 | k-gh from:body -> ermite.go('.brand'); | |
| 541 | 543 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-modify.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -366,7 +368,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 366 | 368 | <dt> | |
| 367 | 369 | <kbd>/</kbd> | |
| 368 | 370 | </dt> | |
| 369 | - | <dd>filter the list</dd> | |
| 371 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 370 | 372 | </div> | |
| 371 | 373 | <div> | |
| 372 | 374 | <dt> | |
| @@ -453,10 +455,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 453 | 455 | </section> | |
| 454 | 456 | </div> | |
| 455 | 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 | 459 | <div class="panel-head"> | |
| 458 | 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 | 462 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 461 | 463 | <kbd>esc</kbd> | |
| 462 | 464 | </button> | |
| @@ -535,7 +537,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 535 | 537 | k-esc from:body -> ermite.closeDialogs(); | |
| 536 | 538 | k-help from:body -> ermite.dialog('panel-help'); | |
| 537 | 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 | 541 | k-filter from:body -> ermite.focusFilter(); | |
| 540 | 542 | k-gh from:body -> ermite.go('.brand'); | |
| 541 | 543 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-rename.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -271,7 +273,7 @@ | |||
| 271 | 273 | <dt> | |
| 272 | 274 | <kbd>/</kbd> | |
| 273 | 275 | </dt> | |
| 274 | - | <dd>filter the list</dd> | |
| 276 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 275 | 277 | </div> | |
| 276 | 278 | <div> | |
| 277 | 279 | <dt> | |
| @@ -358,10 +360,10 @@ | |||
| 358 | 360 | </section> | |
| 359 | 361 | </div> | |
| 360 | 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 | 364 | <div class="panel-head"> | |
| 363 | 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 | 367 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 366 | 368 | <kbd>esc</kbd> | |
| 367 | 369 | </button> | |
| @@ -440,7 +442,7 @@ | |||
| 440 | 442 | k-esc from:body -> ermite.closeDialogs(); | |
| 441 | 443 | k-help from:body -> ermite.dialog('panel-help'); | |
| 442 | 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 | 446 | k-filter from:body -> ermite.focusFilter(); | |
| 445 | 447 | k-gh from:body -> ermite.go('.brand'); | |
| 446 | 448 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-root.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -587,7 +589,7 @@ | |||
| 587 | 589 | <dt> | |
| 588 | 590 | <kbd>/</kbd> | |
| 589 | 591 | </dt> | |
| 590 | - | <dd>filter the list</dd> | |
| 592 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 591 | 593 | </div> | |
| 592 | 594 | <div> | |
| 593 | 595 | <dt> | |
| @@ -674,10 +676,10 @@ | |||
| 674 | 676 | </section> | |
| 675 | 677 | </div> | |
| 676 | 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 | 680 | <div class="panel-head"> | |
| 679 | 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 | 683 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 682 | 684 | <kbd>esc</kbd> | |
| 683 | 685 | </button> | |
| @@ -756,7 +758,7 @@ | |||
| 756 | 758 | k-esc from:body -> ermite.closeDialogs(); | |
| 757 | 759 | k-help from:body -> ermite.dialog('panel-help'); | |
| 758 | 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 | 762 | k-filter from:body -> ermite.focusFilter(); | |
| 761 | 763 | k-gh from:body -> ermite.go('.brand'); | |
| 762 | 764 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commit-trojan.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -380,7 +382,7 @@ | |||
| 380 | 382 | <dt> | |
| 381 | 383 | <kbd>/</kbd> | |
| 382 | 384 | </dt> | |
| 383 | - | <dd>filter the list</dd> | |
| 385 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 384 | 386 | </div> | |
| 385 | 387 | <div> | |
| 386 | 388 | <dt> | |
| @@ -467,10 +469,10 @@ | |||
| 467 | 469 | </section> | |
| 468 | 470 | </div> | |
| 469 | 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 | 473 | <div class="panel-head"> | |
| 472 | 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 | 476 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 475 | 477 | <kbd>esc</kbd> | |
| 476 | 478 | </button> | |
| @@ -549,7 +551,7 @@ | |||
| 549 | 551 | k-esc from:body -> ermite.closeDialogs(); | |
| 550 | 552 | k-help from:body -> ermite.dialog('panel-help'); | |
| 551 | 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 | 555 | k-filter from:body -> ermite.focusFilter(); | |
| 554 | 556 | k-gh from:body -> ermite.go('.brand'); | |
| 555 | 557 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commits-page2.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -201,7 +203,7 @@ | |||
| 201 | 203 | <dt> | |
| 202 | 204 | <kbd>/</kbd> | |
| 203 | 205 | </dt> | |
| 204 | - | <dd>filter the list</dd> | |
| 206 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 205 | 207 | </div> | |
| 206 | 208 | <div> | |
| 207 | 209 | <dt> | |
| @@ -280,10 +282,10 @@ | |||
| 280 | 282 | </section> | |
| 281 | 283 | </div> | |
| 282 | 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 | 286 | <div class="panel-head"> | |
| 285 | 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 | 289 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 288 | 290 | <kbd>esc</kbd> | |
| 289 | 291 | </button> | |
| @@ -356,7 +358,7 @@ | |||
| 356 | 358 | k-esc from:body -> ermite.closeDialogs(); | |
| 357 | 359 | k-help from:body -> ermite.dialog('panel-help'); | |
| 358 | 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 | 362 | k-filter from:body -> ermite.focusFilter(); | |
| 361 | 363 | k-gh from:body -> ermite.go('.brand'); | |
| 362 | 364 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commits-path.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -216,7 +218,7 @@ | |||
| 216 | 218 | <dt> | |
| 217 | 219 | <kbd>/</kbd> | |
| 218 | 220 | </dt> | |
| 219 | - | <dd>filter the list</dd> | |
| 221 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 220 | 222 | </div> | |
| 221 | 223 | <div> | |
| 222 | 224 | <dt> | |
| @@ -295,10 +297,10 @@ | |||
| 295 | 297 | </section> | |
| 296 | 298 | </div> | |
| 297 | 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 | 301 | <div class="panel-head"> | |
| 300 | 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 | 304 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 303 | 305 | <kbd>esc</kbd> | |
| 304 | 306 | </button> | |
| @@ -371,7 +373,7 @@ | |||
| 371 | 373 | k-esc from:body -> ermite.closeDialogs(); | |
| 372 | 374 | k-help from:body -> ermite.dialog('panel-help'); | |
| 373 | 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 | 377 | k-filter from:body -> ermite.focusFilter(); | |
| 376 | 378 | k-gh from:body -> ermite.go('.brand'); | |
| 377 | 379 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commits-root.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -1005,7 +1007,7 @@ | |||
| 1005 | 1007 | <dt> | |
| 1006 | 1008 | <kbd>/</kbd> | |
| 1007 | 1009 | </dt> | |
| 1008 | - | <dd>filter the list</dd> | |
| 1010 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 1009 | 1011 | </div> | |
| 1010 | 1012 | <div> | |
| 1011 | 1013 | <dt> | |
| @@ -1084,10 +1086,10 @@ | |||
| 1084 | 1086 | </section> | |
| 1085 | 1087 | </div> | |
| 1086 | 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 | 1090 | <div class="panel-head"> | |
| 1089 | 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 | 1093 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 1092 | 1094 | <kbd>esc</kbd> | |
| 1093 | 1095 | </button> | |
| @@ -1160,7 +1162,7 @@ | |||
| 1160 | 1162 | k-esc from:body -> ermite.closeDialogs(); | |
| 1161 | 1163 | k-help from:body -> ermite.dialog('panel-help'); | |
| 1162 | 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 | 1166 | k-filter from:body -> ermite.focusFilter(); | |
| 1165 | 1167 | k-gh from:body -> ermite.go('.brand'); | |
| 1166 | 1168 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/commits-small.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -350,7 +352,7 @@ | |||
| 350 | 352 | <dt> | |
| 351 | 353 | <kbd>/</kbd> | |
| 352 | 354 | </dt> | |
| 353 | - | <dd>filter the list</dd> | |
| 355 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 354 | 356 | </div> | |
| 355 | 357 | <div> | |
| 356 | 358 | <dt> | |
| @@ -437,10 +439,10 @@ | |||
| 437 | 439 | </section> | |
| 438 | 440 | </div> | |
| 439 | 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 | 443 | <div class="panel-head"> | |
| 442 | 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 | 446 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 445 | 447 | <kbd>esc</kbd> | |
| 446 | 448 | </button> | |
| @@ -525,7 +527,7 @@ | |||
| 525 | 527 | k-esc from:body -> ermite.closeDialogs(); | |
| 526 | 528 | k-help from:body -> ermite.dialog('panel-help'); | |
| 527 | 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 | 531 | k-filter from:body -> ermite.focusFilter(); | |
| 530 | 532 | k-gh from:body -> ermite.go('.brand'); | |
| 531 | 533 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/empty.html +6 -4
| @@ -17,6 +17,8 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 22 | </head> | |
| 21 | 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 22 | 24 | <a class="skip" href="#main">skip to content</a> | |
| @@ -127,7 +129,7 @@ | |||
| 127 | 129 | <dt> | |
| 128 | 130 | <kbd>/</kbd> | |
| 129 | 131 | </dt> | |
| 130 | - | <dd>filter the list</dd> | |
| 132 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 131 | 133 | </div> | |
| 132 | 134 | <div> | |
| 133 | 135 | <dt> | |
| @@ -179,10 +181,10 @@ | |||
| 179 | 181 | </div> | |
| 180 | 182 | </dl> | |
| 181 | 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 | 185 | <div class="panel-head"> | |
| 184 | 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 | 188 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 187 | 189 | <kbd>esc</kbd> | |
| 188 | 190 | </button> | |
| @@ -219,7 +221,7 @@ | |||
| 219 | 221 | k-esc from:body -> ermite.closeDialogs(); | |
| 220 | 222 | k-help from:body -> ermite.dialog('panel-help'); | |
| 221 | 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 | 225 | k-filter from:body -> ermite.focusFilter(); | |
| 224 | 226 | k-gh from:body -> ermite.go('.brand'); | |
| 225 | 227 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/home.html +6 -4
| @@ -17,6 +17,8 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 22 | </head> | |
| 21 | 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 22 | 24 | <a class="skip" href="#main">skip to content</a> | |
| @@ -159,7 +161,7 @@ | |||
| 159 | 161 | <dt> | |
| 160 | 162 | <kbd>/</kbd> | |
| 161 | 163 | </dt> | |
| 162 | - | <dd>filter the list</dd> | |
| 164 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 163 | 165 | </div> | |
| 164 | 166 | <div> | |
| 165 | 167 | <dt> | |
| @@ -211,10 +213,10 @@ | |||
| 211 | 213 | </div> | |
| 212 | 214 | </dl> | |
| 213 | 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 | 217 | <div class="panel-head"> | |
| 216 | 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 | 220 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 219 | 221 | <kbd>esc</kbd> | |
| 220 | 222 | </button> | |
| @@ -257,7 +259,7 @@ | |||
| 257 | 259 | k-esc from:body -> ermite.closeDialogs(); | |
| 258 | 260 | k-help from:body -> ermite.dialog('panel-help'); | |
| 259 | 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 | 263 | k-filter from:body -> ermite.focusFilter(); | |
| 262 | 264 | k-gh from:body -> ermite.go('.brand'); | |
| 263 | 265 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/not-found-page.html +6 -4
| @@ -17,6 +17,8 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 22 | </head> | |
| 21 | 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 22 | 24 | <a class="skip" href="#main">skip to content</a> | |
| @@ -116,7 +118,7 @@ | |||
| 116 | 118 | <dt> | |
| 117 | 119 | <kbd>/</kbd> | |
| 118 | 120 | </dt> | |
| 119 | - | <dd>filter the list</dd> | |
| 121 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 120 | 122 | </div> | |
| 121 | 123 | <div> | |
| 122 | 124 | <dt> | |
| @@ -168,10 +170,10 @@ | |||
| 168 | 170 | </div> | |
| 169 | 171 | </dl> | |
| 170 | 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 | 174 | <div class="panel-head"> | |
| 173 | 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 | 177 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 176 | 178 | <kbd>esc</kbd> | |
| 177 | 179 | </button> | |
| @@ -202,7 +204,7 @@ | |||
| 202 | 204 | k-esc from:body -> ermite.closeDialogs(); | |
| 203 | 205 | k-help from:body -> ermite.dialog('panel-help'); | |
| 204 | 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 | 208 | k-filter from:body -> ermite.focusFilter(); | |
| 207 | 209 | k-gh from:body -> ermite.go('.brand'); | |
| 208 | 210 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/not-found.html +6 -4
| @@ -17,6 +17,8 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 22 | </head> | |
| 21 | 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 22 | 24 | <a class="skip" href="#main">skip to content</a> | |
| @@ -116,7 +118,7 @@ | |||
| 116 | 118 | <dt> | |
| 117 | 119 | <kbd>/</kbd> | |
| 118 | 120 | </dt> | |
| 119 | - | <dd>filter the list</dd> | |
| 121 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 120 | 122 | </div> | |
| 121 | 123 | <div> | |
| 122 | 124 | <dt> | |
| @@ -168,10 +170,10 @@ | |||
| 168 | 170 | </div> | |
| 169 | 171 | </dl> | |
| 170 | 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 | 174 | <div class="panel-head"> | |
| 173 | 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 | 177 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 176 | 178 | <kbd>esc</kbd> | |
| 177 | 179 | </button> | |
| @@ -202,7 +204,7 @@ | |||
| 202 | 204 | k-esc from:body -> ermite.closeDialogs(); | |
| 203 | 205 | k-help from:body -> ermite.dialog('panel-help'); | |
| 204 | 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 | 208 | k-filter from:body -> ermite.focusFilter(); | |
| 207 | 209 | k-gh from:body -> ermite.go('.brand'); | |
| 208 | 210 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/refs.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -242,7 +244,7 @@ | |||
| 242 | 244 | <dt> | |
| 243 | 245 | <kbd>/</kbd> | |
| 244 | 246 | </dt> | |
| 245 | - | <dd>filter the list</dd> | |
| 247 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 246 | 248 | </div> | |
| 247 | 249 | <div> | |
| 248 | 250 | <dt> | |
| @@ -333,10 +335,10 @@ | |||
| 333 | 335 | </section> | |
| 334 | 336 | </div> | |
| 335 | 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 | 339 | <div class="panel-head"> | |
| 338 | 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 | 342 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 341 | 343 | <kbd>esc</kbd> | |
| 342 | 344 | </button> | |
| @@ -427,7 +429,7 @@ | |||
| 427 | 429 | k-esc from:body -> ermite.closeDialogs(); | |
| 428 | 430 | k-help from:body -> ermite.dialog('panel-help'); | |
| 429 | 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 | 433 | k-filter from:body -> ermite.focusFilter(); | |
| 432 | 434 | k-gh from:body -> ermite.go('.brand'); | |
| 433 | 435 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/summary.html +7 -5
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -261,7 +263,7 @@ | |||
| 261 | 263 | <dt> | |
| 262 | 264 | <kbd>/</kbd> | |
| 263 | 265 | </dt> | |
| 264 | - | <dd>filter the list</dd> | |
| 266 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 265 | 267 | </div> | |
| 266 | 268 | <div> | |
| 267 | 269 | <dt> | |
| @@ -352,10 +354,10 @@ | |||
| 352 | 354 | </section> | |
| 353 | 355 | </div> | |
| 354 | 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 | 358 | <div class="panel-head"> | |
| 357 | 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 | 361 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 360 | 362 | <kbd>esc</kbd> | |
| 361 | 363 | </button> | |
| @@ -446,7 +448,7 @@ | |||
| 446 | 448 | k-esc from:body -> ermite.closeDialogs(); | |
| 447 | 449 | k-help from:body -> ermite.dialog('panel-help'); | |
| 448 | 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 | 452 | k-filter from:body -> ermite.focusFilter(); | |
| 451 | 453 | k-gh from:body -> ermite.go('.brand'); | |
| 452 | 454 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/tree-nested.html +18 -14
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -90,9 +92,16 @@ | |||
| 90 | 92 | <div class="work"> | |
| 91 | 93 | <main id="main" tabindex="-1"> | |
| 92 | 94 | <div class="filter"> | |
| 93 | - | <label for="tree-filter" class="sr">filter</label> | |
| 94 | - | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 95 | + | <label for="finder-q" class="sr">find a file</label> | |
| 96 | + | <input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)"> | |
| 97 | + | </div> | |
| 98 | + | <div id="finder" hidden> | |
| 99 | + | <p id="finder-status" class="dim" aria-live="polite"> | |
| 100 | + | </p> | |
| 101 | + | <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"> | |
| 102 | + | </ul> | |
| 95 | 103 | </div> | |
| 104 | + | <div id="tree-view"> | |
| 96 | 105 | <table class="list tree" aria-label="Entries"> | |
| 97 | 106 | <thead> | |
| 98 | 107 | <tr> | |
| @@ -107,7 +116,7 @@ | |||
| 107 | 116 | <th class="c-when">date</th> | |
| 108 | 117 | </tr> | |
| 109 | 118 | </thead> | |
| 110 | - | <tbody id="tree-rows" data-rows> | |
| 119 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> | |
| 111 | 120 | <tr data-cursor> | |
| 112 | 121 | <td class="cur" aria-hidden="true"> | |
| 113 | 122 | </td> | |
| @@ -126,6 +135,7 @@ | |||
| 126 | 135 | </tr> | |
| 127 | 136 | </tbody> | |
| 128 | 137 | </table> | |
| 138 | + | </div> | |
| 129 | 139 | </main> | |
| 130 | 140 | </div> | |
| 131 | 141 | <footer class="foot"> | |
| @@ -186,7 +196,7 @@ | |||
| 186 | 196 | <dt> | |
| 187 | 197 | <kbd>/</kbd> | |
| 188 | 198 | </dt> | |
| 189 | - | <dd>filter the list</dd> | |
| 199 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 190 | 200 | </div> | |
| 191 | 201 | <div> | |
| 192 | 202 | <dt> | |
| @@ -273,10 +283,10 @@ | |||
| 273 | 283 | </section> | |
| 274 | 284 | </div> | |
| 275 | 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 | 287 | <div class="panel-head"> | |
| 278 | 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 | 290 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 281 | 291 | <kbd>esc</kbd> | |
| 282 | 292 | </button> | |
| @@ -336,12 +346,6 @@ | |||
| 336 | 346 | <span class="k">tag</span> | |
| 337 | 347 | </a> | |
| 338 | 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 | 349 | </ul> | |
| 346 | 350 | <p class="panel-foot"> | |
| 347 | 351 | <span> | |
| @@ -361,7 +365,7 @@ | |||
| 361 | 365 | k-esc from:body -> ermite.closeDialogs(); | |
| 362 | 366 | k-help from:body -> ermite.dialog('panel-help'); | |
| 363 | 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 | 369 | k-filter from:body -> ermite.focusFilter(); | |
| 366 | 370 | k-gh from:body -> ermite.go('.brand'); | |
| 367 | 371 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/tree-root.html +22 -90
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -84,9 +86,16 @@ | |||
| 84 | 86 | <div class="work"> | |
| 85 | 87 | <main id="main" tabindex="-1"> | |
| 86 | 88 | <div class="filter"> | |
| 87 | - | <label for="tree-filter" class="sr">filter</label> | |
| 88 | - | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 89 | + | <label for="finder-q" class="sr">find a file</label> | |
| 90 | + | <input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)"> | |
| 91 | + | </div> | |
| 92 | + | <div id="finder" hidden> | |
| 93 | + | <p id="finder-status" class="dim" aria-live="polite"> | |
| 94 | + | </p> | |
| 95 | + | <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"> | |
| 96 | + | </ul> | |
| 89 | 97 | </div> | |
| 98 | + | <div id="tree-view"> | |
| 90 | 99 | <table class="list tree" aria-label="Entries"> | |
| 91 | 100 | <thead> | |
| 92 | 101 | <tr> | |
| @@ -101,13 +110,13 @@ | |||
| 101 | 110 | <th class="c-when">date</th> | |
| 102 | 111 | </tr> | |
| 103 | 112 | </thead> | |
| 104 | - | <tbody id="tree-rows" data-rows> | |
| 113 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> | |
| 105 | 114 | <tr data-cursor> | |
| 106 | 115 | <td class="cur" aria-hidden="true"> | |
| 107 | 116 | </td> | |
| 108 | - | <td class="c-kind" aria-hidden="true">▾</td> | |
| 117 | + | <td class="c-kind" aria-hidden="true">▸</td> | |
| 109 | 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 | 120 | </td> | |
| 112 | 121 | <td class="c-size num dim">-</td> | |
| 113 | 122 | <td class="c-sub dim"> | |
| @@ -121,9 +130,9 @@ | |||
| 121 | 130 | <tr> | |
| 122 | 131 | <td class="cur" aria-hidden="true"> | |
| 123 | 132 | </td> | |
| 124 | - | <td class="c-kind" aria-hidden="true">▾</td> | |
| 133 | + | <td class="c-kind" aria-hidden="true">▸</td> | |
| 125 | 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 | 136 | </td> | |
| 128 | 137 | <td class="c-size num dim">-</td> | |
| 129 | 138 | <td class="c-sub dim"> | |
| @@ -353,6 +362,7 @@ | |||
| 353 | 362 | <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p> | |
| 354 | 363 | </article> | |
| 355 | 364 | </section> | |
| 365 | + | </div> | |
| 356 | 366 | </main> | |
| 357 | 367 | </div> | |
| 358 | 368 | <footer class="foot"> | |
| @@ -413,7 +423,7 @@ | |||
| 413 | 423 | <dt> | |
| 414 | 424 | <kbd>/</kbd> | |
| 415 | 425 | </dt> | |
| 416 | - | <dd>filter the list</dd> | |
| 426 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 417 | 427 | </div> | |
| 418 | 428 | <div> | |
| 419 | 429 | <dt> | |
| @@ -500,10 +510,10 @@ | |||
| 500 | 510 | </section> | |
| 501 | 511 | </div> | |
| 502 | 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 | 514 | <div class="panel-head"> | |
| 505 | 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 | 517 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 508 | 518 | <kbd>esc</kbd> | |
| 509 | 519 | </button> | |
| @@ -563,84 +573,6 @@ | |||
| 563 | 573 | <span class="k">tag</span> | |
| 564 | 574 | </a> | |
| 565 | 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 | 576 | </ul> | |
| 645 | 577 | <p class="panel-foot"> | |
| 646 | 578 | <span> | |
| @@ -660,7 +592,7 @@ | |||
| 660 | 592 | k-esc from:body -> ermite.closeDialogs(); | |
| 661 | 593 | k-help from:body -> ermite.dialog('panel-help'); | |
| 662 | 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 | 596 | k-filter from:body -> ermite.focusFilter(); | |
| 665 | 597 | k-gh from:body -> ermite.go('.brand'); | |
| 666 | 598 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/tree-slash-ref.html +18 -14
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -87,9 +89,16 @@ | |||
| 87 | 89 | <div class="work"> | |
| 88 | 90 | <main id="main" tabindex="-1"> | |
| 89 | 91 | <div class="filter"> | |
| 90 | - | <label for="tree-filter" class="sr">filter</label> | |
| 91 | - | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 92 | + | <label for="finder-q" class="sr">find a file</label> | |
| 93 | + | <input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)"> | |
| 94 | + | </div> | |
| 95 | + | <div id="finder" hidden> | |
| 96 | + | <p id="finder-status" class="dim" aria-live="polite"> | |
| 97 | + | </p> | |
| 98 | + | <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"> | |
| 99 | + | </ul> | |
| 92 | 100 | </div> | |
| 101 | + | <div id="tree-view"> | |
| 93 | 102 | <table class="list tree" aria-label="Entries"> | |
| 94 | 103 | <thead> | |
| 95 | 104 | <tr> | |
| @@ -104,7 +113,7 @@ | |||
| 104 | 113 | <th class="c-when">date</th> | |
| 105 | 114 | </tr> | |
| 106 | 115 | </thead> | |
| 107 | - | <tbody id="tree-rows" data-rows> | |
| 116 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> | |
| 108 | 117 | <tr data-cursor> | |
| 109 | 118 | <td class="cur" aria-hidden="true"> | |
| 110 | 119 | </td> | |
| @@ -123,6 +132,7 @@ | |||
| 123 | 132 | </tr> | |
| 124 | 133 | </tbody> | |
| 125 | 134 | </table> | |
| 135 | + | </div> | |
| 126 | 136 | </main> | |
| 127 | 137 | </div> | |
| 128 | 138 | <footer class="foot"> | |
| @@ -183,7 +193,7 @@ | |||
| 183 | 193 | <dt> | |
| 184 | 194 | <kbd>/</kbd> | |
| 185 | 195 | </dt> | |
| 186 | - | <dd>filter the list</dd> | |
| 196 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 187 | 197 | </div> | |
| 188 | 198 | <div> | |
| 189 | 199 | <dt> | |
| @@ -270,10 +280,10 @@ | |||
| 270 | 280 | </section> | |
| 271 | 281 | </div> | |
| 272 | 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 | 284 | <div class="panel-head"> | |
| 275 | 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 | 287 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 278 | 288 | <kbd>esc</kbd> | |
| 279 | 289 | </button> | |
| @@ -333,12 +343,6 @@ | |||
| 333 | 343 | <span class="k">tag</span> | |
| 334 | 344 | </a> | |
| 335 | 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 | 346 | </ul> | |
| 343 | 347 | <p class="panel-foot"> | |
| 344 | 348 | <span> | |
| @@ -358,7 +362,7 @@ | |||
| 358 | 362 | k-esc from:body -> ermite.closeDialogs(); | |
| 359 | 363 | k-help from:body -> ermite.dialog('panel-help'); | |
| 360 | 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 | 366 | k-filter from:body -> ermite.focusFilter(); | |
| 363 | 367 | k-gh from:body -> ermite.go('.brand'); | |
| 364 | 368 | k-s from:body -> ermite.tab('s'); | |
M internal/app/testdata/web/tree-subdir.html +18 -20
| @@ -17,8 +17,10 @@ | |||
| 17 | 17 | </script> | |
| 18 | 18 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | 19 | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 20 | 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 | 24 | <a class="skip" href="#main">skip to content</a> | |
| 23 | 25 | <header class="hdr"> | |
| 24 | 26 | <div class="hdr-top"> | |
| @@ -87,9 +89,16 @@ | |||
| 87 | 89 | <div class="work"> | |
| 88 | 90 | <main id="main" tabindex="-1"> | |
| 89 | 91 | <div class="filter"> | |
| 90 | - | <label for="tree-filter" class="sr">filter</label> | |
| 91 | - | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 92 | + | <label for="finder-q" class="sr">find a file</label> | |
| 93 | + | <input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)"> | |
| 92 | 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 | 102 | <table class="list tree" aria-label="Entries"> | |
| 94 | 103 | <thead> | |
| 95 | 104 | <tr> | |
| @@ -104,7 +113,7 @@ | |||
| 104 | 113 | <th class="c-when">date</th> | |
| 105 | 114 | </tr> | |
| 106 | 115 | </thead> | |
| 107 | - | <tbody id="tree-rows" data-rows> | |
| 116 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> | |
| 108 | 117 | <tr data-cursor> | |
| 109 | 118 | <td class="cur" aria-hidden="true"> | |
| 110 | 119 | </td> | |
| @@ -148,6 +157,7 @@ | |||
| 148 | 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 | 158 | </article> | |
| 150 | 159 | </section> | |
| 160 | + | </div> | |
| 151 | 161 | </main> | |
| 152 | 162 | </div> | |
| 153 | 163 | <footer class="foot"> | |
| @@ -208,7 +218,7 @@ | |||
| 208 | 218 | <dt> | |
| 209 | 219 | <kbd>/</kbd> | |
| 210 | 220 | </dt> | |
| 211 | - | <dd>filter the list</dd> | |
| 221 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 212 | 222 | </div> | |
| 213 | 223 | <div> | |
| 214 | 224 | <dt> | |
| @@ -295,10 +305,10 @@ | |||
| 295 | 305 | </section> | |
| 296 | 306 | </div> | |
| 297 | 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 | 309 | <div class="panel-head"> | |
| 300 | 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 | 312 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 303 | 313 | <kbd>esc</kbd> | |
| 304 | 314 | </button> | |
| @@ -358,18 +368,6 @@ | |||
| 358 | 368 | <span class="k">tag</span> | |
| 359 | 369 | </a> | |
| 360 | 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 | 371 | </ul> | |
| 374 | 372 | <p class="panel-foot"> | |
| 375 | 373 | <span> | |
| @@ -389,7 +387,7 @@ | |||
| 389 | 387 | k-esc from:body -> ermite.closeDialogs(); | |
| 390 | 388 | k-help from:body -> ermite.dialog('panel-help'); | |
| 391 | 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 | 391 | k-filter from:body -> ermite.focusFilter(); | |
| 394 | 392 | k-gh from:body -> ermite.go('.brand'); | |
| 395 | 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 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "encoding/json" | |
| 9 | + | "fmt" | |
| 7 | 10 | "image" | |
| 8 | 11 | "image/color" | |
| 9 | 12 | "image/png" | |
| 13 | + | "io" | |
| 10 | 14 | "net/http" | |
| 15 | + | "os" | |
| 16 | + | "path/filepath" | |
| 11 | 17 | "regexp" | |
| 12 | 18 | "strconv" | |
| 13 | 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 | 181 | // Ref names with a slash are found by their longest prefix, and a commit id | |
| 104 | 182 | // works where a ref does. | |
| 105 | 183 | func TestWebFilesRefs(t *testing.T) { | |
| @@ -248,6 +326,19 @@ func TestWebRaw(t *testing.T) { | |||
| 248 | 326 | require.NoError(t, err) | |
| 249 | 327 | _ = resp.Body.Close() | |
| 250 | 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 | 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 | 422 | assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)") | |
| 332 | 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 | 155 | if s == "" { | |
| 156 | 156 | return TreePath{}, nil | |
| 157 | 157 | } | |
| 158 | + | if why := treePathProblem(s); why != "" { | |
| 159 | + | return TreePath{}, fmt.Errorf("%w: %q: %s", ErrInvalidPath, truncate(s), why) | |
| 160 | + | } | |
| 161 | + | return TreePath{s: s}, nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // IsAddressablePath reports whether s is a non-root path that ParseTreePath | |
| 165 | + | // accepts as it is (no trailing slash). It allocates nothing. | |
| 166 | + | func IsAddressablePath(s string) bool { | |
| 167 | + | return s != "" && treePathProblem(s) == "" | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // IsAddressablePathBytes is IsAddressablePath for a path held in bytes; it | |
| 171 | + | // spares the copy into a string. | |
| 172 | + | func IsAddressablePathBytes(b []byte) bool { | |
| 173 | + | return len(b) > 0 && len(b) <= maxTreePathLen && utf8.Valid(b) && pathShapeProblem(b) == "" | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // treePathProblem returns why s cannot be a non-root tree path, or "". | |
| 177 | + | func treePathProblem(s string) string { | |
| 158 | 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 { | |
| 162 | - | if c < 0x20 || c == 0x7f { | |
| 163 | - | return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s)) | |
| 181 | + | return pathShapeProblem(s) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // pathShapeProblem checks the control characters and the components of a path | |
| 185 | + | // that is valid UTF-8 and short enough. The bytes of a multi-byte character | |
| 186 | + | // are all 0x80 or more, so it looks at bytes. | |
| 187 | + | func pathShapeProblem[S ~string | ~[]byte](s S) string { | |
| 188 | + | start := 0 | |
| 189 | + | for i := 0; i <= len(s); i++ { | |
| 190 | + | if i < len(s) && s[i] != '/' { | |
| 191 | + | if c := s[i]; c < 0x20 || c == 0x7f { | |
| 192 | + | return "control character" | |
| 193 | + | } | |
| 194 | + | continue | |
| 164 | 195 | } | |
| 165 | - | } | |
| 166 | - | for part := range strings.SplitSeq(s, "/") { | |
| 167 | - | if part == "" || part == "." || part == ".." { | |
| 168 | - | return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s)) | |
| 196 | + | n := i - start | |
| 197 | + | if n == 0 || (n == 1 && s[start] == '.') || (n == 2 && s[start] == '.' && s[start+1] == '.') { | |
| 198 | + | return "empty, . or .. component" | |
| 169 | 199 | } | |
| 200 | + | start = i + 1 | |
| 170 | 201 | } | |
| 171 | - | return TreePath{s: s}, nil | |
| 202 | + | return "" | |
| 172 | 203 | } | |
| 173 | 204 | ||
| 174 | 205 | func truncate(s string) string { | |
| @@ -325,6 +356,45 @@ type Tree struct { | |||
| 325 | 356 | // MaxTreeEntries bounds the entries of a listed directory. | |
| 326 | 357 | const MaxTreeEntries = 5000 | |
| 327 | 358 | ||
| 359 | + | // MaxPathEntries bounds the paths ListPaths returns. | |
| 360 | + | const MaxPathEntries = 100_000 | |
| 361 | + | ||
| 362 | + | // MaxPathBytes bounds the total bytes of the paths ListPaths returns. | |
| 363 | + | const MaxPathBytes = 8 << 20 | |
| 364 | + | ||
| 365 | + | // PathList is the recursive list of the file paths of a revision. | |
| 366 | + | type PathList struct { | |
| 367 | + | // CommitID is the commit the revision resolved to. | |
| 368 | + | CommitID ObjectID | |
| 369 | + | // TreeID is the root tree of that commit. | |
| 370 | + | TreeID ObjectID | |
| 371 | + | // Paths are the paths of the files and symbolic links, in tree order (the | |
| 372 | + | // order of `git ls-tree -r`). | |
| 373 | + | Paths []string | |
| 374 | + | // Truncated is true when more paths exist than the ones in Paths. | |
| 375 | + | Truncated bool | |
| 376 | + | ||
| 377 | + | size int // bytes of Paths, kept by Add | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | // Add lists p, the next path of a walk in tree order, within the bounds of | |
| 381 | + | // ListPaths: at most maxPaths paths and maxBytes bytes of paths. A path that | |
| 382 | + | // is not addressable (IsAddressablePath) is left out. When a bound is reached | |
| 383 | + | // p is not listed, Truncated is set, and Add returns false: the walk can stop. | |
| 384 | + | // Otherwise it returns true. The bytes of p are kept, not copied. | |
| 385 | + | func (l *PathList) Add(p string, maxPaths, maxBytes int) bool { | |
| 386 | + | if !IsAddressablePath(p) { | |
| 387 | + | return true | |
| 388 | + | } | |
| 389 | + | if len(l.Paths) >= maxPaths || l.size+len(p) > maxBytes { | |
| 390 | + | l.Truncated = true | |
| 391 | + | return false | |
| 392 | + | } | |
| 393 | + | l.size += len(p) | |
| 394 | + | l.Paths = append(l.Paths, p) | |
| 395 | + | return true | |
| 396 | + | } | |
| 397 | + | ||
| 328 | 398 | // MaxLastCommitEntries bounds the entries of one directory that get a last | |
| 329 | 399 | // commit: each costs a history lookup. | |
| 330 | 400 | const MaxLastCommitEntries = 200 | |
M internal/domain/git_test.go +72 -3
| @@ -77,10 +77,79 @@ func TestParseTreePath(t *testing.T) { | |||
| 77 | 77 | require.NoError(t, err) | |
| 78 | 78 | assert.Equal(t, "a/b", p.String()) | |
| 79 | 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), "//"} { | |
| 82 | - | _, err := domain.ParseTreePath(bad) | |
| 83 | - | assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", bad) | |
| 120 | + | func TestPathListAdd(t *testing.T) { | |
| 121 | + | for _, tt := range []struct { | |
| 122 | + | name string | |
| 123 | + | adds []string | |
| 124 | + | maxPaths, maxBytes int | |
| 125 | + | want []string | |
| 126 | + | wantStopAt int // index of the Add that returned false, -1 for none | |
| 127 | + | truncated bool | |
| 128 | + | }{ | |
| 129 | + | {"all fit", []string{"a", "b/c"}, 10, 100, []string{"a", "b/c"}, -1, false}, | |
| 130 | + | {"not addressable are skipped", []string{"a", "", "b//c", "d/", "e\nf", "g"}, 10, 100, []string{"a", "g"}, -1, false}, | |
| 131 | + | {"skipped paths do not count", []string{"", "..", "a"}, 1, 1, []string{"a"}, -1, false}, | |
| 132 | + | {"exactly maxPaths", []string{"a", "b"}, 2, 100, []string{"a", "b"}, -1, false}, | |
| 133 | + | {"over maxPaths", []string{"a", "b", "c"}, 2, 100, []string{"a", "b"}, 2, true}, | |
| 134 | + | {"exactly maxBytes", []string{"ab", "cd"}, 10, 4, []string{"ab", "cd"}, -1, false}, | |
| 135 | + | {"over maxBytes", []string{"ab", "cde", "f"}, 10, 4, []string{"ab"}, 1, true}, | |
| 136 | + | {"nothing fits", []string{"abc"}, 10, 2, nil, 0, true}, | |
| 137 | + | {"no paths allowed", []string{"a"}, 0, 100, nil, 0, true}, | |
| 138 | + | {"unaddressable past the limit is no truncation", []string{"a", "b/"}, 1, 100, []string{"a"}, -1, false}, | |
| 139 | + | } { | |
| 140 | + | t.Run(tt.name, func(t *testing.T) { | |
| 141 | + | var l domain.PathList | |
| 142 | + | stop := -1 | |
| 143 | + | for i, p := range tt.adds { | |
| 144 | + | if !l.Add(p, tt.maxPaths, tt.maxBytes) { | |
| 145 | + | stop = i | |
| 146 | + | break | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | assert.Equal(t, tt.want, l.Paths) | |
| 150 | + | assert.Equal(t, tt.wantStopAt, stop) | |
| 151 | + | assert.Equal(t, tt.truncated, l.Truncated) | |
| 152 | + | }) | |
| 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 | 295 | 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 | 376 | // ListRefs provides a mock function for the type BrowseService | |
| 299 | 377 | func (_mock *BrowseService) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) { | |
| 300 | 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 | 283 | return _c | |
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | + | // ListPaths provides a mock function for the type RepoReader | |
| 287 | + | func (_mock *RepoReader) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error) { | |
| 288 | + | ret := _mock.Called(ctx, repo, rev, maxPaths, maxBytes) | |
| 289 | + | ||
| 290 | + | if len(ret) == 0 { | |
| 291 | + | panic("no return value specified for ListPaths") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | var r0 domain.PathList | |
| 295 | + | var r1 error | |
| 296 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) (domain.PathList, error)); ok { | |
| 297 | + | return returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |
| 298 | + | } | |
| 299 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) domain.PathList); ok { | |
| 300 | + | r0 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |
| 301 | + | } else { | |
| 302 | + | r0 = ret.Get(0).(domain.PathList) | |
| 303 | + | } | |
| 304 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, int, int) error); ok { | |
| 305 | + | r1 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |
| 306 | + | } else { | |
| 307 | + | r1 = ret.Error(1) | |
| 308 | + | } | |
| 309 | + | return r0, r1 | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // RepoReader_ListPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPaths' | |
| 313 | + | type RepoReader_ListPaths_Call struct { | |
| 314 | + | *mock.Call | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // ListPaths is a helper method to define mock.On call | |
| 318 | + | // - ctx context.Context | |
| 319 | + | // - repo domain.RepoName | |
| 320 | + | // - rev domain.Revision | |
| 321 | + | // - maxPaths int | |
| 322 | + | // - maxBytes int | |
| 323 | + | func (_e *RepoReader_Expecter) ListPaths(ctx any, repo any, rev any, maxPaths any, maxBytes any) *RepoReader_ListPaths_Call { | |
| 324 | + | return &RepoReader_ListPaths_Call{Call: _e.mock.On("ListPaths", ctx, repo, rev, maxPaths, maxBytes)} | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func (_c *RepoReader_ListPaths_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int)) *RepoReader_ListPaths_Call { | |
| 328 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 329 | + | var arg0 context.Context | |
| 330 | + | if args[0] != nil { | |
| 331 | + | arg0 = args[0].(context.Context) | |
| 332 | + | } | |
| 333 | + | var arg1 domain.RepoName | |
| 334 | + | if args[1] != nil { | |
| 335 | + | arg1 = args[1].(domain.RepoName) | |
| 336 | + | } | |
| 337 | + | var arg2 domain.Revision | |
| 338 | + | if args[2] != nil { | |
| 339 | + | arg2 = args[2].(domain.Revision) | |
| 340 | + | } | |
| 341 | + | var arg3 int | |
| 342 | + | if args[3] != nil { | |
| 343 | + | arg3 = args[3].(int) | |
| 344 | + | } | |
| 345 | + | var arg4 int | |
| 346 | + | if args[4] != nil { | |
| 347 | + | arg4 = args[4].(int) | |
| 348 | + | } | |
| 349 | + | run( | |
| 350 | + | arg0, | |
| 351 | + | arg1, | |
| 352 | + | arg2, | |
| 353 | + | arg3, | |
| 354 | + | arg4, | |
| 355 | + | ) | |
| 356 | + | }) | |
| 357 | + | return _c | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func (_c *RepoReader_ListPaths_Call) Return(pathList domain.PathList, err error) *RepoReader_ListPaths_Call { | |
| 361 | + | _c.Call.Return(pathList, err) | |
| 362 | + | return _c | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func (_c *RepoReader_ListPaths_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error)) *RepoReader_ListPaths_Call { | |
| 366 | + | _c.Call.Return(run) | |
| 367 | + | return _c | |
| 368 | + | } | |
| 369 | + | ||
| 286 | 370 | // ListRefs provides a mock function for the type RepoReader | |
| 287 | 371 | func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | |
| 288 | 372 | ret := _mock.Called(ctx, repo) | |
M internal/ports/ports.go +5 -0
| @@ -163,6 +163,11 @@ type RepoReader interface { | |||
| 163 | 163 | // domain.MaxLastCommitEntries entries get their last commit. It returns | |
| 164 | 164 | // domain.ErrNotDirectory if path names something else. | |
| 165 | 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 | 171 | // OpenBlob returns the description of the file at path and a reader of its | |
| 167 | 172 | // content, which streams without holding the whole file in memory. The | |
| 168 | 173 | // 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 | 61 | ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error) | |
| 62 | 62 | ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) | |
| 63 | 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 | 65 | OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) | |
| 65 | 66 | ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error) | |
| 66 | 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 | 278 | t.Run("OpenBlob", func(t *testing.T) { | |
| 268 | 279 | read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) { | |
| 269 | 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 | 349 | _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false) | |
| 339 | 350 | return err | |
| 340 | 351 | }, | |
| 352 | + | "ListPaths": func(r ports.RepoReader) error { | |
| 353 | + | _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 354 | + | return err | |
| 355 | + | }, | |
| 341 | 356 | "OpenBlob": func(r ports.RepoReader) error { | |
| 342 | 357 | _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{}) | |
| 343 | 358 | return err | |
M internal/readertest/conformance.go +108 -0
| @@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) { | |||
| 46 | 46 | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | |
| 47 | 47 | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | |
| 48 | 48 | {"GetTreeIsBounded", testGetTreeIsBounded}, | |
| 49 | + | {"ListPaths", testListPaths}, | |
| 49 | 50 | } { | |
| 50 | 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 | 702 | require.NoError(t, err) | |
| 702 | 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 | 63 | {"refs", buildRefs}, | |
| 64 | 64 | {"dangling ref", buildDangling}, | |
| 65 | 65 | {"submodule", buildSubmodule}, | |
| 66 | + | {"paths", buildPaths}, | |
| 66 | 67 | {"wide directory", buildWide}, | |
| 67 | 68 | {"detached HEAD", func(t *testing.T, data string) *Fixture { | |
| 68 | 69 | h := NewHistory(t, data) | |
| @@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture { | |||
| 265 | 266 | return f | |
| 266 | 267 | } | |
| 267 | 268 | ||
| 269 | + | // buildPaths has what ListPaths must order and skip: nested directories whose | |
| 270 | + | // names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a | |
| 271 | + | // symlink, a submodule, and a name with a tab that no address can carry. The | |
| 272 | + | // tag v1 points at the commit. | |
| 273 | + | func buildPaths(t *testing.T, data string) *Fixture { | |
| 274 | + | f := NewFixture(t, data, "paths") | |
| 275 | + | f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n") | |
| 276 | + | f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link") | |
| 277 | + | f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n") | |
| 278 | + | f.Commit("base") | |
| 279 | + | f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod") | |
| 280 | + | f.Git("commit", "--quiet", "-m", "add submodule") | |
| 281 | + | f.Git("tag", "v1") | |
| 282 | + | f.Maintain() | |
| 283 | + | return f | |
| 284 | + | } | |
| 285 | + | ||
| 268 | 286 | // buildWide has a directory over MaxTreeEntries entries and one over | |
| 269 | 287 | // MaxLastCommitEntries, with commits that touch some of them. | |
| 270 | 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 | 203 | 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 | 215 | // OpenBlob returns the description of a file and a reader of its content. The | |
| 207 | 216 | // caller closes the reader. | |
| 208 | 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 | 76 | }, func(r *mocks.RepoReader) { | |
| 77 | 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 | 85 | {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 80 | 86 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | |
| 81 | 87 | if rc != nil { | |