Merge pull request 'harden git HTTP, web headers and UI, speed up browsing, modernize tests' (#10) from fix/review-hardening into main
Reviewed-on: https://git.bnema.dev/bnema/ermite/pulls/10
252 files · +16294 -8189
M .air.toml +1 -1
| @@ -10,7 +10,7 @@ tmp_dir = "tmp" | |||||
| 10 | entrypoint = ["./tmp/ermite", "serve"] | 10 | entrypoint = ["./tmp/ermite", "serve"] | ||
| 11 | # The web assets are embedded (go:embed static), so they need a rebuild too. | 11 | # The web assets are embedded (go:embed static), so they need a rebuild too. | ||
| 12 | include_ext = ["go", "templ", "css", "js", "svg", "json", "woff2"] | 12 | include_ext = ["go", "templ", "css", "js", "svg", "json", "woff2"] | ||
| 13 | - | exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "cmd/ermite/testdata"] | 13 | + | exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "internal/app/testdata"] |
| 14 | # *_templ.go is the output of templ generate: a .templ change triggers the | 14 | # *_templ.go is the output of templ generate: a .templ change triggers the | ||
| 15 | # rebuild, the generated Go file does not trigger a second one. | 15 | # rebuild, the generated Go file does not trigger a second one. | ||
| 16 | exclude_regex = ["_test\\.go", "_templ\\.go"] | 16 | exclude_regex = ["_test\\.go", "_templ\\.go"] | ||
M .golangci.yml +1 -0
| @@ -7,6 +7,7 @@ linters: | |||||
| 7 | - ineffassign | 7 | - ineffassign | ||
| 8 | - misspell | 8 | - misspell | ||
| 9 | - staticcheck | 9 | - staticcheck | ||
| 10 | + | - tparallel | |||
| 10 | - unconvert | 11 | - unconvert | ||
| 11 | - unused | 12 | - unused | ||
| 12 | exclusions: | 13 | exclusions: | ||
M .hexcheck.yaml +46 -2
| @@ -4,10 +4,10 @@ components: | |||||
| 4 | role: core | 4 | role: core | ||
| 5 | paths: | 5 | paths: | ||
| 6 | - internal/domain/** | 6 | - internal/domain/** | ||
| 7 | - | app: | 7 | + | usecase: |
| 8 | role: usecase | 8 | role: usecase | ||
| 9 | paths: | 9 | paths: | ||
| 10 | - | - internal/app/** | 10 | + | - internal/usecase/** |
| 11 | ports: | 11 | ports: | ||
| 12 | role: ports | 12 | role: ports | ||
| 13 | paths: | 13 | paths: | ||
| @@ -16,10 +16,23 @@ components: | |||||
| 16 | role: adapter | 16 | role: adapter | ||
| 17 | paths: | 17 | paths: | ||
| 18 | - internal/adapters/** | 18 | - internal/adapters/** | ||
| 19 | + | clientlimit: | |||
| 20 | + | # Per-client HTTP rate limiting: infrastructure shared by the HTTP adapters. | |||
| 21 | + | role: adapter | |||
| 22 | + | paths: | |||
| 23 | + | - internal/clientlimit/** | |||
| 19 | entrypoints: | 24 | entrypoints: | ||
| 25 | + | # cmd/ermite only calls app.Run. | |||
| 20 | role: entrypoint | 26 | role: entrypoint | ||
| 21 | paths: | 27 | paths: | ||
| 22 | - cmd/** | 28 | - cmd/** | ||
| 29 | + | app: | |||
| 30 | + | # Composition root and CLI: builds the adapters and wires them to the use | |||
| 31 | + | # cases. hexcheck has no dedicated bootstrap role; entrypoint is the | |||
| 32 | + | # closest. | |||
| 33 | + | role: entrypoint | |||
| 34 | + | paths: | |||
| 35 | + | - internal/app/** | |||
| 23 | config: | 36 | config: | ||
| 24 | role: entrypoint | 37 | role: entrypoint | ||
| 25 | paths: | 38 | paths: | ||
| @@ -30,8 +43,39 @@ components: | |||||
| 30 | - gen/** | 43 | - gen/** | ||
| 31 | - '**/mocks/**' | 44 | - '**/mocks/**' | ||
| 32 | - '**/*_templ.go' | 45 | - '**/*_templ.go' | ||
| 46 | + | readertest: | |||
| 47 | + | # Test-only fixtures and conformance suite for ports.RepoReader. | |||
| 48 | + | role: ignore | |||
| 49 | + | paths: | |||
| 50 | + | - internal/readertest/** | |||
| 51 | + | # Cross-layer contracts live in ports: adapters and use cases import ports and | |||
| 52 | + | # domain, never each other. hexcheck has no rule for an adapter importing a use | |||
| 53 | + | # case or internal/app; internal/boundary/boundary_test.go enforces the full | |||
| 54 | + | # import matrix. | |||
| 55 | + | rules: | |||
| 56 | + | no-adapter-imports-in-core: error | |||
| 57 | + | no-infra-imports-in-usecase: error | |||
| 58 | + | no-framework-types-in-core: error | |||
| 59 | + | no-infra-types-in-ports: error | |||
| 60 | + | no-adapter-to-adapter-imports: warn | |||
| 61 | + | suspicious-business-logic-in-adapter: warn | |||
| 62 | + | no-local-fakes-for-ports: warn | |||
| 63 | + | missing-generated-mock-for-port: warn | |||
| 64 | + | prefer-generated-mocks: warn | |||
| 33 | mocking: | 65 | mocking: | ||
| 34 | generatedMockPaths: | 66 | generatedMockPaths: | ||
| 35 | - internal/ports/mocks/** | 67 | - internal/ports/mocks/** | ||
| 36 | generatedMockNamePatterns: | 68 | generatedMockNamePatterns: | ||
| 37 | - '{{Interface}}' | 69 | - '{{Interface}}' | ||
| 70 | + | allow: | |||
| 71 | + | # clientlimit is a shared infrastructure library, not a sibling adapter: the | |||
| 72 | + | # HTTP adapters that rate-limit clients import it by design. | |||
| 73 | + | - rule: no-adapter-to-adapter-imports | |||
| 74 | + | path: internal/adapters/githttp/** | |||
| 75 | + | reason: rate-limits Git clients with internal/clientlimit | |||
| 76 | + | - rule: no-adapter-to-adapter-imports | |||
| 77 | + | path: internal/adapters/rpc/** | |||
| 78 | + | reason: rate-limits API clients with internal/clientlimit | |||
| 79 | + | - rule: no-adapter-to-adapter-imports | |||
| 80 | + | path: internal/adapters/web/** | |||
| 81 | + | reason: rate-limits login attempts with internal/clientlimit | |||
M .mockery.yml +10 -19
| @@ -1,22 +1,13 @@ | |||||
| 1 | - | # Mocks of the ports. Regenerate with: go generate ./internal/ports | 1 | + | # Mocks of the ports, generated in internal/ports/mocks. Regenerate with: |
| 2 | + | # go generate ./internal/ports | |||
| 2 | template: testify | 3 | template: testify | ||
| 4 | + | _anchors: | |||
| 5 | + | mocks: &mocks | |||
| 6 | + | dir: "{{.InterfaceDir}}/mocks" | |||
| 7 | + | pkgname: mocks | |||
| 8 | + | filename: "{{.InterfaceName}}.go" | |||
| 9 | + | structname: "{{.InterfaceName}}" | |||
| 10 | + | all: true | |||
| 3 | packages: | 11 | packages: | ||
| 4 | bnema.dev/ermite/internal/ports: | 12 | bnema.dev/ermite/internal/ports: | ||
| 5 | - | config: | 13 | + | config: *mocks |
| 6 | - | dir: "{{.InterfaceDir}}/mocks" | |||
| 7 | - | pkgname: mocks | |||
| 8 | - | filename: "{{.InterfaceName}}.go" | |||
| 9 | - | structname: "{{.InterfaceName}}" | |||
| 10 | - | interfaces: | |||
| 11 | - | RepoMetaStore: | |||
| 12 | - | GitRepos: | |||
| 13 | - | Clock: | |||
| 14 | - | TokenStore: | |||
| 15 | - | SessionStore: | |||
| 16 | - | GitBackend: | |||
| 17 | - | EventBus: | |||
| 18 | - | RepoReader: | |||
| 19 | - | MergeRequestStore: | |||
| 20 | - | MergeGit: | |||
| 21 | - | CommitSigner: | |||
| 22 | - | RepoFreshener: | |||
M Dockerfile +1 -1
| @@ -24,7 +24,7 @@ COPY . . | |||||
| 24 | ARG VERSION= | 24 | ARG VERSION= | ||
| 25 | RUN --mount=type=cache,target=/go/pkg/mod \ | 25 | RUN --mount=type=cache,target=/go/pkg/mod \ | ||
| 26 | --mount=type=cache,target=/root/.cache/go-build \ | 26 | --mount=type=cache,target=/root/.cache/go-build \ | ||
| 27 | - | go build -trimpath -buildvcs=false -ldflags "-s -w -X main.stamped=${VERSION}" \ | 27 | + | go build -trimpath -buildvcs=false -ldflags "-s -w -X bnema.dev/ermite/internal/app.stamped=${VERSION}" \ |
| 28 | -o /out/ermite ./cmd/ermite | 28 | -o /out/ermite ./cmd/ermite | ||
| 29 | 29 | ||||
| 30 | FROM alpine:3.24 | 30 | FROM alpine:3.24 | ||
M README.md +12 -0
| @@ -98,6 +98,18 @@ go build ./cmd/ermite | |||||
| 98 | go test ./... | 98 | go test ./... | ||
| 99 | ``` | 99 | ``` | ||
| 100 | 100 | ||||
| 101 | + | ## Architecture | |||
| 102 | + | ||||
| 103 | + | `cmd/ermite` (entry point, calls `app.Run`) → `internal/app` (CLI and composition root: builds the adapters and wires them to the use cases) → `internal/usecase` (use cases), `internal/ports` (interfaces and parameter types that cross layers), `internal/domain` (pure values and rules) and `internal/adapters` (Git, KV store, HTTP, API, web, client implementations of the ports). | |||
| 104 | + | ||||
| 105 | + | - `internal/usecase` imports only `internal/domain` and `internal/ports`. | |||
| 106 | + | - Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter. | |||
| 107 | + | - The web adapter, like the API and Git over HTTP, calls the use cases through `internal/ports` with the principal of the request (anonymous, or the owner with a valid session); it never imports the generated API code. | |||
| 108 | + | - `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module. | |||
| 109 | + | - `internal/config` (environment parsing) is used by `internal/app` only. | |||
| 110 | + | ||||
| 111 | + | `internal/boundary/boundary_test.go` enforces these import rules on production files; `hexcheck` (`.hexcheck.yaml`) checks the layering of types and behaviour. The release version is stamped with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`. | |||
| 112 | + | ||||
| 101 | ## Develop | 113 | ## Develop | ||
| 102 | 114 | ||||
| 103 | Tools are pinned in `go.mod`: | 115 | Tools are pinned in `go.mod`: | ||
D cmd/ermite/events_test.go +0 -403
| @@ -1,403 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package main | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | "fmt" | |||
| 9 | - | "net/http" | |||
| 10 | - | "os/exec" | |||
| 11 | - | "syscall" | |||
| 12 | - | "testing" | |||
| 13 | - | "time" | |||
| 14 | - | ||||
| 15 | - | "connectrpc.com/connect" | |||
| 16 | - | "github.com/stretchr/testify/assert" | |||
| 17 | - | "github.com/stretchr/testify/require" | |||
| 18 | - | "google.golang.org/protobuf/types/known/fieldmaskpb" | |||
| 19 | - | ||||
| 20 | - | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 21 | - | "bnema.dev/ermite/internal/adapters/rpc" | |||
| 22 | - | "bnema.dev/ermite/internal/app" | |||
| 23 | - | "bnema.dev/ermite/internal/domain" | |||
| 24 | - | ) | |||
| 25 | - | ||||
| 26 | - | // watchWait is how long a test waits for an event that must arrive. Only | |||
| 27 | - | // TestAC5PushEventWithinASecond holds the server to a second. | |||
| 28 | - | const watchWait = 10 * time.Second | |||
| 29 | - | ||||
| 30 | - | // AC5: a push shows up in Watch within a second, with the ref updates. | |||
| 31 | - | func TestAC5PushEventWithinASecond(t *testing.T) { | |||
| 32 | - | h := newHarness(t, harnessOpts{}) | |||
| 33 | - | h.seed("pub", domain.VisibilityPublic) | |||
| 34 | - | w := h.watch(h.api(""), "", "home") | |||
| 35 | - | ||||
| 36 | - | wt := h.workTree("main", "a.txt") | |||
| 37 | - | start := time.Now() | |||
| 38 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 39 | - | ev := w.next(t, time.Second) | |||
| 40 | - | assert.Less(t, time.Since(start), 2*time.Second) | |||
| 41 | - | ||||
| 42 | - | pushed := ev.GetPushed() | |||
| 43 | - | require.NotNil(t, pushed, "got %v", ev) | |||
| 44 | - | assert.Equal(t, "pub", pushed.GetRepo()) | |||
| 45 | - | require.Len(t, pushed.GetRefUpdates(), 1) | |||
| 46 | - | up := pushed.GetRefUpdates()[0] | |||
| 47 | - | assert.Equal(t, "refs/heads/main", up.GetRef()) | |||
| 48 | - | assert.Empty(t, up.GetOldId(), "a created ref has no old id") | |||
| 49 | - | assert.Equal(t, h.bareGit("pub", "rev-parse", "main"), up.GetNewId()) | |||
| 50 | - | assert.NotEmpty(t, ev.GetId()) | |||
| 51 | - | assert.NotNil(t, ev.GetTime()) | |||
| 52 | - | ||||
| 53 | - | // A second push to the same ref carries both ids; a deletion has no new id. | |||
| 54 | - | old := up.GetNewId() | |||
| 55 | - | h.writeFile(wt, "b.txt", []byte("b\n")) | |||
| 56 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 57 | - | up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |||
| 58 | - | assert.Equal(t, old, up2.GetOldId()) | |||
| 59 | - | assert.NotEqual(t, old, up2.GetNewId()) | |||
| 60 | - | ||||
| 61 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | |||
| 62 | - | w.next(t, watchWait) | |||
| 63 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | |||
| 64 | - | del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |||
| 65 | - | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | |||
| 66 | - | assert.NotEmpty(t, del.GetOldId()) | |||
| 67 | - | assert.Empty(t, del.GetNewId()) | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | // AC5: creating, updating and deleting repositories through the API publish | |||
| 71 | - | // RepoCreated, RepoUpdated and RepoDeleted; so does a create-on-push. | |||
| 72 | - | func TestAC5RepoLifecycleEvents(t *testing.T) { | |||
| 73 | - | h := newHarness(t, harnessOpts{}) | |||
| 74 | - | root := h.api(adminToken) | |||
| 75 | - | w := h.watch(root, "") | |||
| 76 | - | ||||
| 77 | - | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) | |||
| 78 | - | require.NoError(t, err) | |||
| 79 | - | created := w.next(t, watchWait).GetRepoCreated().GetRepo() | |||
| 80 | - | require.NotNil(t, created) | |||
| 81 | - | assert.Equal(t, "life", created.GetName()) | |||
| 82 | - | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) | |||
| 83 | - | assert.Equal(t, "main", created.GetDefaultBranch()) | |||
| 84 | - | ||||
| 85 | - | _, err = root.repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 86 | - | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |||
| 87 | - | })) | |||
| 88 | - | require.NoError(t, err) | |||
| 89 | - | updated := w.next(t, watchWait).GetRepoUpdated().GetRepo() | |||
| 90 | - | require.NotNil(t, updated) | |||
| 91 | - | assert.Equal(t, "new", updated.GetDescription()) | |||
| 92 | - | ||||
| 93 | - | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) | |||
| 94 | - | require.NoError(t, err) | |||
| 95 | - | assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo()) | |||
| 96 | - | ||||
| 97 | - | // Create on first push publishes RepoCreated once, then the push. | |||
| 98 | - | wt := h.workTree("main", "a.txt") | |||
| 99 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") | |||
| 100 | - | assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |||
| 101 | - | assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo()) | |||
| 102 | - | // Nothing else came in between: the next event is the sentinel. | |||
| 103 | - | h.sentinel(t, w, "s-life") | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | // AC5: resume from an id replays what came after, then goes live. | |||
| 107 | - | func TestAC5Resume(t *testing.T) { | |||
| 108 | - | h := newHarness(t, harnessOpts{}) | |||
| 109 | - | h.seed("pub", domain.VisibilityPublic) | |||
| 110 | - | api := h.api("") | |||
| 111 | - | w := h.watch(api, "") | |||
| 112 | - | ||||
| 113 | - | wt := h.workTree("main", "a.txt") | |||
| 114 | - | var ids []string | |||
| 115 | - | for i := range 4 { | |||
| 116 | - | if i > 0 { | |||
| 117 | - | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |||
| 118 | - | } | |||
| 119 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 120 | - | ids = append(ids, w.next(t, watchWait).GetId()) | |||
| 121 | - | } | |||
| 122 | - | w.cancel() | |||
| 123 | - | ||||
| 124 | - | // The client lost its connection after ids[1]: it gets 2 and 3, then live. | |||
| 125 | - | r := h.watch(api, ids[1]) | |||
| 126 | - | for _, want := range ids[2:] { | |||
| 127 | - | ev := r.next(t, watchWait) | |||
| 128 | - | assert.Equal(t, want, ev.GetId()) | |||
| 129 | - | assert.NotNil(t, ev.GetPushed()) | |||
| 130 | - | } | |||
| 131 | - | // Nothing was replayed twice: the next event is the live one, below. | |||
| 132 | - | h.writeFile(wt, "live.txt", []byte("x\n")) | |||
| 133 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 134 | - | live := r.next(t, watchWait) | |||
| 135 | - | assert.NotNil(t, live.GetPushed()) | |||
| 136 | - | assert.NotContains(t, ids, live.GetId()) | |||
| 137 | - | ||||
| 138 | - | // An id nobody issued is a gap, followed by the oldest buffered event: the | |||
| 139 | - | // creation of the repository, which came before the first push. | |||
| 140 | - | g := h.watch(api, "not-an-id") | |||
| 141 | - | assert.NotNil(t, g.next(t, watchWait).GetGap()) | |||
| 142 | - | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |||
| 143 | - | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | // AC5: a gap when the id fell out of a small ring. | |||
| 147 | - | func TestAC5GapAfterOverflow(t *testing.T) { | |||
| 148 | - | h := newHarness(t, harnessOpts{eventBuffer: 4}) | |||
| 149 | - | h.seed("pub", domain.VisibilityPublic) | |||
| 150 | - | api := h.api(adminToken) | |||
| 151 | - | w := h.watch(api, "") | |||
| 152 | - | wt := h.workTree("main", "a.txt") | |||
| 153 | - | ||||
| 154 | - | var ids []string | |||
| 155 | - | for i := range 8 { | |||
| 156 | - | if i > 0 { | |||
| 157 | - | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |||
| 158 | - | } | |||
| 159 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 160 | - | ids = append(ids, w.next(t, watchWait).GetId()) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // ids[2] is out of the ring (the last four are ids[4:]). | |||
| 164 | - | r := h.watch(api, ids[2]) | |||
| 165 | - | gap := r.next(t, watchWait) | |||
| 166 | - | assert.NotNil(t, gap.GetGap()) | |||
| 167 | - | assert.Empty(t, gap.GetId()) | |||
| 168 | - | for _, want := range ids[4:] { | |||
| 169 | - | assert.Equal(t, want, r.next(t, watchWait).GetId()) | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | // The last buffered id still resumes without a gap. | |||
| 173 | - | ok := h.watch(api, ids[4]) | |||
| 174 | - | assert.Equal(t, ids[5], ok.next(t, watchWait).GetId()) | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | // AC5: private events never reach anonymous callers, nor tokens that may not | |||
| 178 | - | // read the repository; repo topics follow the same rule. | |||
| 179 | - | func TestAC5NoPrivateEventsToAnonymous(t *testing.T) { | |||
| 180 | - | h := newHarness(t, harnessOpts{}) | |||
| 181 | - | h.seed("pub", domain.VisibilityPublic) | |||
| 182 | - | h.seed("priv", domain.VisibilityPrivate) | |||
| 183 | - | other := h.token(scopes(domain.ScopeRepoRead), "pub") | |||
| 184 | - | reader := h.token(scopes(domain.ScopeRepoRead), "priv") | |||
| 185 | - | ||||
| 186 | - | anon := h.watch(h.api(""), "") | |||
| 187 | - | scoped := h.watch(h.api(other), "") | |||
| 188 | - | rd := h.watch(h.api(reader), "") | |||
| 189 | - | root := h.watch(h.api(adminToken), "") | |||
| 190 | - | ||||
| 191 | - | // A private repository's topic is not found for those who cannot read it. | |||
| 192 | - | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |||
| 193 | - | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(other), "repo:priv"))) | |||
| 194 | - | assert.Equal(t, codeName(watchErr(h.ctx, h.api(""), "repo:nothing")), codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |||
| 195 | - | ||||
| 196 | - | wt := h.workTree("main", "a.txt") | |||
| 197 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |||
| 198 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 199 | - | // Metadata events too. | |||
| 200 | - | _, err := h.api(adminToken).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 201 | - | Repo: "priv", Description: "secret plans", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |||
| 202 | - | })) | |||
| 203 | - | require.NoError(t, err) | |||
| 204 | - | ||||
| 205 | - | // The reader of priv sees its push and its update, and pub's push. | |||
| 206 | - | assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo()) | |||
| 207 | - | assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public") | |||
| 208 | - | assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription()) | |||
| 209 | - | // Root sees everything in order. | |||
| 210 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo()) | |||
| 211 | - | assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo()) | |||
| 212 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName()) | |||
| 213 | - | ||||
| 214 | - | // Deleting reaches those who could read the repository before, not others. | |||
| 215 | - | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | |||
| 216 | - | require.NoError(t, err) | |||
| 217 | - | assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo()) | |||
| 218 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo()) | |||
| 219 | - | ||||
| 220 | - | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |||
| 221 | - | ev := w.next(t, watchWait) | |||
| 222 | - | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | |||
| 223 | - | } | |||
| 224 | - | // Nothing about priv came before the sentinel, for either of them. | |||
| 225 | - | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic}) | |||
| 226 | - | require.NoError(t, err) | |||
| 227 | - | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |||
| 228 | - | assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name) | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | // Resuming does not replay private events to anonymous either: after a | |||
| 232 | - | // gap, the creation and the push of pub, the sentinels the watchers above | |||
| 233 | - | // saw, and nothing about priv. | |||
| 234 | - | r := h.watch(h.api(""), "bogus") | |||
| 235 | - | assert.NotNil(t, r.next(t, watchWait).GetGap()) | |||
| 236 | - | assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |||
| 237 | - | assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo()) | |||
| 238 | - | assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |||
| 239 | - | h.sentinel(t, r, "s-resumed") | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | // AC5: a repo topic only carries that repository; a revoked token cannot | |||
| 243 | - | // start a new watch. | |||
| 244 | - | func TestAC5RepoTopic(t *testing.T) { | |||
| 245 | - | h := newHarness(t, harnessOpts{}) | |||
| 246 | - | h.seed("a", domain.VisibilityPublic) | |||
| 247 | - | h.seed("b", domain.VisibilityPublic) | |||
| 248 | - | w := h.watch(h.api(""), "", "repo:a") | |||
| 249 | - | ||||
| 250 | - | wt := h.workTree("main", "x.txt") | |||
| 251 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") | |||
| 252 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |||
| 253 | - | assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo()) | |||
| 254 | - | // The push to b was not delivered: the next event is the next push to a. | |||
| 255 | - | h.writeFile(wt, "y.txt", []byte("y\n")) | |||
| 256 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |||
| 257 | - | assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId()) | |||
| 258 | - | } | |||
| 259 | - | ||||
| 260 | - | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. | |||
| 261 | - | func TestWatchEndsOnCancel(t *testing.T) { | |||
| 262 | - | h := newHarness(t, harnessOpts{}) | |||
| 263 | - | w := h.watch(h.api(""), "") | |||
| 264 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 265 | - | w.cancel() | |||
| 266 | - | select { | |||
| 267 | - | case <-w.done: | |||
| 268 | - | case <-time.After(5 * watchWait): | |||
| 269 | - | t.Fatal("the stream did not end") | |||
| 270 | - | } | |||
| 271 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |||
| 272 | - | } | |||
| 273 | - | ||||
| 274 | - | // Shutdown ends the streams cleanly: the client sees the stream end without | |||
| 275 | - | // an error and the server exits 0 well before the shutdown timeout. | |||
| 276 | - | func TestServeShutdownEndsWatchStreams(t *testing.T) { | |||
| 277 | - | requireGit(t) | |||
| 278 | - | data := t.TempDir() | |||
| 279 | - | addr := freeAddr(t) | |||
| 280 | - | cmd := exec.Command(binary(t), "serve") | |||
| 281 | - | cmd.Env = append(baseEnv(), | |||
| 282 | - | "ERMITE_ADDR="+addr, "ERMITE_DATA_DIR="+data, "ERMITE_PUBLIC_URL=http://"+addr, | |||
| 283 | - | "ERMITE_ADMIN_TOKEN="+adminToken, "ERMITE_LOG_LEVEL=debug", "ERMITE_LOG_FORMAT=json", | |||
| 284 | - | ) | |||
| 285 | - | var logs syncBuffer | |||
| 286 | - | cmd.Stdout, cmd.Stderr = &logs, &logs | |||
| 287 | - | require.NoError(t, cmd.Start()) | |||
| 288 | - | exited := make(chan error, 1) | |||
| 289 | - | go func() { exited <- cmd.Wait() }() | |||
| 290 | - | t.Cleanup(func() { _ = cmd.Process.Kill() }) | |||
| 291 | - | ||||
| 292 | - | base := "http://" + addr | |||
| 293 | - | require.Eventually(t, func() bool { | |||
| 294 | - | resp, err := http.Get(base + "/-/health") | |||
| 295 | - | if err != nil { | |||
| 296 | - | return false | |||
| 297 | - | } | |||
| 298 | - | _ = resp.Body.Close() | |||
| 299 | - | return resp.StatusCode == 200 | |||
| 300 | - | }, 15*time.Second, 50*time.Millisecond, "%s", logs.String()) | |||
| 301 | - | ||||
| 302 | - | c := newClients(base+"/-/api", adminToken) | |||
| 303 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 304 | - | defer cancel() | |||
| 305 | - | st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |||
| 306 | - | require.NoError(t, err) | |||
| 307 | - | ended := make(chan error, 1) | |||
| 308 | - | go func() { | |||
| 309 | - | for st.Receive() { | |||
| 310 | - | } | |||
| 311 | - | ended <- st.Err() | |||
| 312 | - | }() | |||
| 313 | - | ||||
| 314 | - | // The server version comes from the real binary. | |||
| 315 | - | v, err := c.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |||
| 316 | - | require.NoError(t, err) | |||
| 317 | - | assert.NotEmpty(t, v.Msg.GetVersion()) | |||
| 318 | - | ||||
| 319 | - | start := time.Now() | |||
| 320 | - | require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) | |||
| 321 | - | select { | |||
| 322 | - | case err := <-ended: | |||
| 323 | - | assert.NoError(t, err, "the stream ends cleanly") | |||
| 324 | - | case <-time.After(15 * watchWait): | |||
| 325 | - | t.Fatalf("the stream did not end\n%s", logs.String()) | |||
| 326 | - | } | |||
| 327 | - | select { | |||
| 328 | - | case err := <-exited: | |||
| 329 | - | require.NoError(t, err, "%s", logs.String()) | |||
| 330 | - | case <-time.After(15 * watchWait): | |||
| 331 | - | t.Fatalf("the server did not exit\n%s", logs.String()) | |||
| 332 | - | } | |||
| 333 | - | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | |||
| 334 | - | assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String()) | |||
| 335 | - | assert.False(t, errors.Is(err, context.Canceled)) | |||
| 336 | - | } | |||
| 337 | - | ||||
| 338 | - | // A token that is revoked while its Watch is open ends the stream with | |||
| 339 | - | // unauthenticated, and the private events that follow never reach it. | |||
| 340 | - | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |||
| 341 | - | h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond}) | |||
| 342 | - | h.seed("priv", domain.VisibilityPrivate) | |||
| 343 | - | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{ | |||
| 344 | - | Label: "watcher", Scopes: scopes(domain.ScopeRepoRead), | |||
| 345 | - | }) | |||
| 346 | - | require.NoError(t, err) | |||
| 347 | - | w := h.watch(h.api(created.Secret), "") | |||
| 348 | - | wt := h.workTree("main", "a.txt") | |||
| 349 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |||
| 350 | - | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") | |||
| 351 | - | ||||
| 352 | - | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) | |||
| 353 | - | h.writeFile(wt, "b.txt", []byte("b\n")) | |||
| 354 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |||
| 355 | - | ||||
| 356 | - | select { | |||
| 357 | - | case <-w.done: | |||
| 358 | - | case <-time.After(5 * watchWait): | |||
| 359 | - | t.Fatal("the stream of a revoked token did not end") | |||
| 360 | - | } | |||
| 361 | - | assert.Equal(t, "unauthenticated", codeName(w.err)) | |||
| 362 | - | for ev := range w.events { | |||
| 363 | - | t.Fatalf("an event reached the revoked token: %v", ev) | |||
| 364 | - | } | |||
| 365 | - | } | |||
| 366 | - | ||||
| 367 | - | // Watch refuses too many topics, and too many streams from one client. | |||
| 368 | - | func TestWatchLimits(t *testing.T) { | |||
| 369 | - | h := newHarness(t, harnessOpts{}) | |||
| 370 | - | api := h.api(adminToken) | |||
| 371 | - | ||||
| 372 | - | topics := make([]string, app.MaxWatchTopics+1) | |||
| 373 | - | for i := range topics { | |||
| 374 | - | topics[i] = "home" | |||
| 375 | - | } | |||
| 376 | - | assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...))) | |||
| 377 | - | ||||
| 378 | - | for range app.MaxWatchersPerClient { | |||
| 379 | - | h.watch(api, "") | |||
| 380 | - | } | |||
| 381 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |||
| 382 | - | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | |||
| 383 | - | // A stream of another client address is not affected (the harness trusts | |||
| 384 | - | // the loopback proxy only when configured: use the in-process caller). | |||
| 385 | - | inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | |||
| 386 | - | ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root())) | |||
| 387 | - | defer cancel() | |||
| 388 | - | st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |||
| 389 | - | require.NoError(t, err) | |||
| 390 | - | _ = st.Close() | |||
| 391 | - | } | |||
| 392 | - | ||||
| 393 | - | // sentinel proves that nothing reached the stream since the last event it | |||
| 394 | - | // returned: it creates a public repository, which every watcher of "home" may | |||
| 395 | - | // see, and requires its RepoCreated to be the next event. Events arrive in the | |||
| 396 | - | // order they were published. | |||
| 397 | - | func (h *harness) sentinel(t *testing.T, s *stream, name string) { | |||
| 398 | - | t.Helper() | |||
| 399 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |||
| 400 | - | require.NoError(t, err) | |||
| 401 | - | ev := s.next(t, watchWait) | |||
| 402 | - | assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev) | |||
| 403 | - | } | |||
M cmd/ermite/main.go +4 -23
| @@ -6,32 +6,13 @@ package main | |||||
| 6 | import ( | 6 | import ( | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | - | "runtime/debug" | 9 | + | |
| 10 | + | "bnema.dev/ermite/internal/app" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | func main() { | 13 | func main() { | ||
| 13 | - | if err := newRootCmd().Execute(); err != nil { | 14 | + | if err := app.Run(os.Args[1:]); err != nil { |
| 14 | fmt.Fprintln(os.Stderr, "ermite:", err) | 15 | fmt.Fprintln(os.Stderr, "ermite:", err) | ||
| 15 | - | os.Exit(1) | 16 | + | os.Exit(app.ExitCode(err)) |
| 16 | } | 17 | } | ||
| 17 | } | 18 | } | ||
| 18 | - | ||||
| 19 | - | // stamped is the release version set at link time by the image build | |||
| 20 | - | // (-ldflags "-X main.stamped=v0.1.0"). Empty for a plain go build, which then | |||
| 21 | - | // reports the module version recorded by the Go toolchain. | |||
| 22 | - | var stamped string | |||
| 23 | - | ||||
| 24 | - | // buildVersion is the version stamped into the binary: the link-time version | |||
| 25 | - | // when set, else the module version of the build. | |||
| 26 | - | func buildVersion() string { | |||
| 27 | - | if stamped != "" { | |||
| 28 | - | return stamped | |||
| 29 | - | } | |||
| 30 | - | info, ok := debug.ReadBuildInfo() | |||
| 31 | - | if !ok || info.Main.Version == "" { | |||
| 32 | - | return "(devel)" | |||
| 33 | - | } | |||
| 34 | - | return info.Main.Version | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | func version() string { return "ermite " + buildVersion() } | |||
D cmd/ermite/testdata/web/empty.html +0 -284
| @@ -1,284 +0,0 @@ | |||||
| 1 | - | <!doctype html> | |||
| 2 | - | <html lang="en"> | |||
| 3 | - | <head> | |||
| 4 | - | <meta charset="utf-8"> | |||
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | - | <meta name="color-scheme" content="light dark"> | |||
| 7 | - | <title>quiet · example.test</title> | |||
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 12 | - | </script> | |||
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 14 | - | </script> | |||
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 16 | - | </script> | |||
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 18 | - | </script> | |||
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | - | </script> | |||
| 21 | - | </head> | |||
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 23 | - | <a class="skip" href="#main">skip to content</a> | |||
| 24 | - | <header class="hdr"> | |||
| 25 | - | <div class="hdr-top"> | |||
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |||
| 28 | - | </span> | |||
| 29 | - | <span class="brand-name">example.test</span> | |||
| 30 | - | </a> | |||
| 31 | - | <nav class="crumbs" aria-label="Path"> | |||
| 32 | - | <ol> | |||
| 33 | - | <li> | |||
| 34 | - | <a href="/quiet" aria-current="page">quiet</a> | |||
| 35 | - | </li> | |||
| 36 | - | </ol> | |||
| 37 | - | </nav> | |||
| 38 | - | <div class="hdr-acts"> | |||
| 39 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 40 | - | <span class="ico i-search" aria-hidden="true"> | |||
| 41 | - | </span> | |||
| 42 | - | <span class="search-label">Go to…</span> | |||
| 43 | - | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 44 | - | </button> | |||
| 45 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |||
| 46 | - | </div> | |||
| 47 | - | </div> | |||
| 48 | - | </header> | |||
| 49 | - | <div class="work"> | |||
| 50 | - | <main id="main" tabindex="-1"> | |||
| 51 | - | <section class="state" aria-labelledby="state-title"> | |||
| 52 | - | <p class="eyebrow">empty</p> | |||
| 53 | - | <h1 id="state-title">quiet is empty</h1> | |||
| 54 | - | <p class="dim">no commits yet. push a branch to create one.</p> | |||
| 55 | - | <div class="clone" id="clone-quiet"> | |||
| 56 | - | <div class="clone-line"> | |||
| 57 | - | <span class="mute" aria-hidden="true">$ </span> | |||
| 58 | - | <code>git remote add origin http://example.test/quiet.git</code> | |||
| 59 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |||
| 60 | - | </div> | |||
| 61 | - | <div class="clone-line"> | |||
| 62 | - | <span class="mute" aria-hidden="true">$ </span> | |||
| 63 | - | <code>git push -u origin main</code> | |||
| 64 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |||
| 65 | - | </div> | |||
| 66 | - | </div> | |||
| 67 | - | <p class="state-back"> | |||
| 68 | - | <a href="/">home</a> | |||
| 69 | - | </p> | |||
| 70 | - | </section> | |||
| 71 | - | </main> | |||
| 72 | - | </div> | |||
| 73 | - | <footer class="foot"> | |||
| 74 | - | <span>ermite devel</span> | |||
| 75 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 76 | - | <span class="foot-keys"> | |||
| 77 | - | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 78 | - | </footer> | |||
| 79 | - | <div class="hint" role="status" aria-live="polite"> | |||
| 80 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 81 | - | </span> | |||
| 82 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 83 | - | </span> | |||
| 84 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 85 | - | </span> | |||
| 86 | - | </div> | |||
| 87 | - | <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()"> | |||
| 88 | - | <div class="panel-head"> | |||
| 89 | - | <h2>Keyboard shortcuts</h2> | |||
| 90 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 91 | - | <kbd>esc</kbd> close</button> | |||
| 92 | - | </div> | |||
| 93 | - | <dl class="keys"> | |||
| 94 | - | <div> | |||
| 95 | - | <dt> | |||
| 96 | - | <kbd>ctrl k</kbd> | |||
| 97 | - | <kbd>:</kbd> | |||
| 98 | - | </dt> | |||
| 99 | - | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 100 | - | </div> | |||
| 101 | - | <div> | |||
| 102 | - | <dt> | |||
| 103 | - | <kbd>?</kbd> | |||
| 104 | - | </dt> | |||
| 105 | - | <dd>this list</dd> | |||
| 106 | - | </div> | |||
| 107 | - | <div> | |||
| 108 | - | <dt> | |||
| 109 | - | <kbd>j</kbd> | |||
| 110 | - | <kbd>k</kbd> | |||
| 111 | - | </dt> | |||
| 112 | - | <dd>move the row cursor down / up</dd> | |||
| 113 | - | </div> | |||
| 114 | - | <div> | |||
| 115 | - | <dt> | |||
| 116 | - | <kbd>enter</kbd> | |||
| 117 | - | </dt> | |||
| 118 | - | <dd>open the row under the cursor</dd> | |||
| 119 | - | </div> | |||
| 120 | - | <div> | |||
| 121 | - | <dt> | |||
| 122 | - | <kbd>g g</kbd> | |||
| 123 | - | <kbd>G</kbd> | |||
| 124 | - | </dt> | |||
| 125 | - | <dd>first / last row</dd> | |||
| 126 | - | </div> | |||
| 127 | - | <div> | |||
| 128 | - | <dt> | |||
| 129 | - | <kbd>/</kbd> | |||
| 130 | - | </dt> | |||
| 131 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 132 | - | </div> | |||
| 133 | - | <div> | |||
| 134 | - | <dt> | |||
| 135 | - | <kbd>b</kbd> | |||
| 136 | - | </dt> | |||
| 137 | - | <dd>switch branch or tag</dd> | |||
| 138 | - | </div> | |||
| 139 | - | <div> | |||
| 140 | - | <dt> | |||
| 141 | - | <kbd>s</kbd> | |||
| 142 | - | <kbd>t</kbd> | |||
| 143 | - | <kbd>c</kbd> | |||
| 144 | - | <kbd>r</kbd> | |||
| 145 | - | <kbd>m</kbd> | |||
| 146 | - | </dt> | |||
| 147 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 148 | - | </div> | |||
| 149 | - | <div> | |||
| 150 | - | <dt> | |||
| 151 | - | <kbd>g h</kbd> | |||
| 152 | - | </dt> | |||
| 153 | - | <dd>home</dd> | |||
| 154 | - | </div> | |||
| 155 | - | <div> | |||
| 156 | - | <dt> | |||
| 157 | - | <kbd>y</kbd> | |||
| 158 | - | </dt> | |||
| 159 | - | <dd>copy a permalink pinned to the commit</dd> | |||
| 160 | - | </div> | |||
| 161 | - | <div> | |||
| 162 | - | <dt> | |||
| 163 | - | <kbd>d</kbd> | |||
| 164 | - | <kbd>u</kbd> | |||
| 165 | - | <kbd>x</kbd> | |||
| 166 | - | </dt> | |||
| 167 | - | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 168 | - | </div> | |||
| 169 | - | <div> | |||
| 170 | - | <dt> | |||
| 171 | - | <kbd>n</kbd> | |||
| 172 | - | </dt> | |||
| 173 | - | <dd>merge request: next note in the diff</dd> | |||
| 174 | - | </div> | |||
| 175 | - | <div> | |||
| 176 | - | <dt> | |||
| 177 | - | <kbd>esc</kbd> | |||
| 178 | - | </dt> | |||
| 179 | - | <dd>close a dialog, leave a field</dd> | |||
| 180 | - | </div> | |||
| 181 | - | </dl> | |||
| 182 | - | </dialog> | |||
| 183 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |||
| 184 | - | <div class="panel-head"> | |||
| 185 | - | <label for="cmd-q" class="sr">go to</label> | |||
| 186 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 187 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 188 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 189 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 190 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 191 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 192 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 193 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 194 | - | <kbd>esc</kbd> | |||
| 195 | - | </button> | |||
| 196 | - | </div> | |||
| 197 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 198 | - | <li data-k="go home" data-cursor> | |||
| 199 | - | <a href="/" tabindex="-1"> | |||
| 200 | - | <span class="lbl">home</span> | |||
| 201 | - | <span class="k">go</span> | |||
| 202 | - | </a> | |||
| 203 | - | </li> | |||
| 204 | - | <li data-k="repo demo"> | |||
| 205 | - | <a href="/demo" tabindex="-1"> | |||
| 206 | - | <span class="lbl">demo</span> | |||
| 207 | - | <span class="k">repo</span> | |||
| 208 | - | </a> | |||
| 209 | - | </li> | |||
| 210 | - | </ul> | |||
| 211 | - | <p class="panel-foot"> | |||
| 212 | - | <span> | |||
| 213 | - | <kbd>↑</kbd> | |||
| 214 | - | <kbd>↓</kbd> move</span> | |||
| 215 | - | <span> | |||
| 216 | - | <kbd>enter</kbd> open</span> | |||
| 217 | - | <span> | |||
| 218 | - | <kbd>esc</kbd> close</span> | |||
| 219 | - | </p> | |||
| 220 | - | </dialog> | |||
| 221 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |||
| 222 | - | if (q('dialog[open]').count) return; | |||
| 223 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 224 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 225 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 226 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 227 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 228 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 229 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 230 | - | })(1); | |||
| 231 | - | k-k from:body -> (async (d) => { | |||
| 232 | - | if (q('dialog[open]').count) return; | |||
| 233 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 234 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 235 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 236 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 237 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 238 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 239 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 240 | - | })(-1); | |||
| 241 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 242 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 243 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 244 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 245 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 246 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 247 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 248 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 249 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 250 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 251 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 252 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 253 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 254 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 255 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 256 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 257 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 258 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 259 | - | k-next from:body -> { | |||
| 260 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 261 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 262 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 263 | - | }; | |||
| 264 | - | k-split from:body -> { | |||
| 265 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 266 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 267 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 268 | - | }; | |||
| 269 | - | k-unified from:body -> { | |||
| 270 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 271 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 272 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 273 | - | }; | |||
| 274 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 275 | - | k-permalink from:body -> { | |||
| 276 | - | let b = document.body.dataset; | |||
| 277 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 278 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 279 | - | navigator.clipboard?.writeText(location.href); | |||
| 280 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 281 | - | }"> | |||
| 282 | - | </div> | |||
| 283 | - | </body> | |||
| 284 | - | </html> | |||
D cmd/ermite/testdata/web/home.html +0 -320
| @@ -1,320 +0,0 @@ | |||||
| 1 | - | <!doctype html> | |||
| 2 | - | <html lang="en"> | |||
| 3 | - | <head> | |||
| 4 | - | <meta charset="utf-8"> | |||
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | - | <meta name="color-scheme" content="light dark"> | |||
| 7 | - | <title>example.test</title> | |||
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 12 | - | </script> | |||
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 14 | - | </script> | |||
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 16 | - | </script> | |||
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 18 | - | </script> | |||
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | - | </script> | |||
| 21 | - | </head> | |||
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 23 | - | <a class="skip" href="#main">skip to content</a> | |||
| 24 | - | <header class="hdr"> | |||
| 25 | - | <div class="hdr-top"> | |||
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |||
| 28 | - | </span> | |||
| 29 | - | <span class="brand-name">example.test</span> | |||
| 30 | - | </a> | |||
| 31 | - | <div class="hdr-acts"> | |||
| 32 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 33 | - | <span class="ico i-search" aria-hidden="true"> | |||
| 34 | - | </span> | |||
| 35 | - | <span class="search-label">Go to…</span> | |||
| 36 | - | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 37 | - | </button> | |||
| 38 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |||
| 39 | - | </div> | |||
| 40 | - | </div> | |||
| 41 | - | </header> | |||
| 42 | - | <div class="work"> | |||
| 43 | - | <main id="main" tabindex="-1"> | |||
| 44 | - | <div class="filter"> | |||
| 45 | - | <label for="home-filter" class="sr">filter</label> | |||
| 46 | - | <input id="home-filter" type="search" data-filter placeholder="Filter repositories…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')"> | |||
| 47 | - | </div> | |||
| 48 | - | <table class="list home" aria-label="Repositories"> | |||
| 49 | - | <thead> | |||
| 50 | - | <tr> | |||
| 51 | - | <th class="cur"> | |||
| 52 | - | </th> | |||
| 53 | - | <th class="c-name" aria-sort="none"> | |||
| 54 | - | <a href="/?sort=name&dir=asc">name </a> | |||
| 55 | - | </th> | |||
| 56 | - | <th class="c-desc" aria-sort="none"> | |||
| 57 | - | <a href="/?sort=desc&dir=asc">description </a> | |||
| 58 | - | </th> | |||
| 59 | - | <th class="c-when" aria-sort="descending"> | |||
| 60 | - | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |||
| 61 | - | </a> | |||
| 62 | - | </th> | |||
| 63 | - | <th class="c-ref" aria-sort="none"> | |||
| 64 | - | <a href="/?sort=branch&dir=asc">branch </a> | |||
| 65 | - | </th> | |||
| 66 | - | </tr> | |||
| 67 | - | </thead> | |||
| 68 | - | <tbody id="repo-rows" data-rows> | |||
| 69 | - | <tr id="repo-demo" data-cursor> | |||
| 70 | - | <td class="cur" aria-hidden="true"> | |||
| 71 | - | </td> | |||
| 72 | - | <td class="c-name"> | |||
| 73 | - | <a href="/demo">demo</a> | |||
| 74 | - | </td> | |||
| 75 | - | <td class="c-desc dim">A demo repository</td> | |||
| 76 | - | <td class="c-when nowrap"> | |||
| 77 | - | <time datetime="STAMP">AGO</time> | |||
| 78 | - | </td> | |||
| 79 | - | <td class="c-ref">main</td> | |||
| 80 | - | </tr> | |||
| 81 | - | <tr id="repo-quiet"> | |||
| 82 | - | <td class="cur" aria-hidden="true"> | |||
| 83 | - | </td> | |||
| 84 | - | <td class="c-name"> | |||
| 85 | - | <a href="/quiet">quiet</a> | |||
| 86 | - | </td> | |||
| 87 | - | <td class="c-desc dim"> | |||
| 88 | - | </td> | |||
| 89 | - | <td class="c-when nowrap"> | |||
| 90 | - | <time>never</time> | |||
| 91 | - | </td> | |||
| 92 | - | <td class="c-ref">main</td> | |||
| 93 | - | </tr> | |||
| 94 | - | </tbody> | |||
| 95 | - | </table> | |||
| 96 | - | </main> | |||
| 97 | - | </div> | |||
| 98 | - | <footer class="foot"> | |||
| 99 | - | <span>ermite devel</span> | |||
| 100 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 101 | - | <span class="foot-keys"> | |||
| 102 | - | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 103 | - | </footer> | |||
| 104 | - | <div class="hint" role="status" aria-live="polite"> | |||
| 105 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 106 | - | </span> | |||
| 107 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 108 | - | </span> | |||
| 109 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 110 | - | </span> | |||
| 111 | - | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |||
| 112 | - | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 113 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |||
| 114 | - | gap -> ermiteLive.reload()"> | |||
| 115 | - | </span> | |||
| 116 | - | </div> | |||
| 117 | - | <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()"> | |||
| 118 | - | <div class="panel-head"> | |||
| 119 | - | <h2>Keyboard shortcuts</h2> | |||
| 120 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 121 | - | <kbd>esc</kbd> close</button> | |||
| 122 | - | </div> | |||
| 123 | - | <dl class="keys"> | |||
| 124 | - | <div> | |||
| 125 | - | <dt> | |||
| 126 | - | <kbd>ctrl k</kbd> | |||
| 127 | - | <kbd>:</kbd> | |||
| 128 | - | </dt> | |||
| 129 | - | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 130 | - | </div> | |||
| 131 | - | <div> | |||
| 132 | - | <dt> | |||
| 133 | - | <kbd>?</kbd> | |||
| 134 | - | </dt> | |||
| 135 | - | <dd>this list</dd> | |||
| 136 | - | </div> | |||
| 137 | - | <div> | |||
| 138 | - | <dt> | |||
| 139 | - | <kbd>j</kbd> | |||
| 140 | - | <kbd>k</kbd> | |||
| 141 | - | </dt> | |||
| 142 | - | <dd>move the row cursor down / up</dd> | |||
| 143 | - | </div> | |||
| 144 | - | <div> | |||
| 145 | - | <dt> | |||
| 146 | - | <kbd>enter</kbd> | |||
| 147 | - | </dt> | |||
| 148 | - | <dd>open the row under the cursor</dd> | |||
| 149 | - | </div> | |||
| 150 | - | <div> | |||
| 151 | - | <dt> | |||
| 152 | - | <kbd>g g</kbd> | |||
| 153 | - | <kbd>G</kbd> | |||
| 154 | - | </dt> | |||
| 155 | - | <dd>first / last row</dd> | |||
| 156 | - | </div> | |||
| 157 | - | <div> | |||
| 158 | - | <dt> | |||
| 159 | - | <kbd>/</kbd> | |||
| 160 | - | </dt> | |||
| 161 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 162 | - | </div> | |||
| 163 | - | <div> | |||
| 164 | - | <dt> | |||
| 165 | - | <kbd>b</kbd> | |||
| 166 | - | </dt> | |||
| 167 | - | <dd>switch branch or tag</dd> | |||
| 168 | - | </div> | |||
| 169 | - | <div> | |||
| 170 | - | <dt> | |||
| 171 | - | <kbd>s</kbd> | |||
| 172 | - | <kbd>t</kbd> | |||
| 173 | - | <kbd>c</kbd> | |||
| 174 | - | <kbd>r</kbd> | |||
| 175 | - | <kbd>m</kbd> | |||
| 176 | - | </dt> | |||
| 177 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 178 | - | </div> | |||
| 179 | - | <div> | |||
| 180 | - | <dt> | |||
| 181 | - | <kbd>g h</kbd> | |||
| 182 | - | </dt> | |||
| 183 | - | <dd>home</dd> | |||
| 184 | - | </div> | |||
| 185 | - | <div> | |||
| 186 | - | <dt> | |||
| 187 | - | <kbd>y</kbd> | |||
| 188 | - | </dt> | |||
| 189 | - | <dd>copy a permalink pinned to the commit</dd> | |||
| 190 | - | </div> | |||
| 191 | - | <div> | |||
| 192 | - | <dt> | |||
| 193 | - | <kbd>d</kbd> | |||
| 194 | - | <kbd>u</kbd> | |||
| 195 | - | <kbd>x</kbd> | |||
| 196 | - | </dt> | |||
| 197 | - | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 198 | - | </div> | |||
| 199 | - | <div> | |||
| 200 | - | <dt> | |||
| 201 | - | <kbd>n</kbd> | |||
| 202 | - | </dt> | |||
| 203 | - | <dd>merge request: next note in the diff</dd> | |||
| 204 | - | </div> | |||
| 205 | - | <div> | |||
| 206 | - | <dt> | |||
| 207 | - | <kbd>esc</kbd> | |||
| 208 | - | </dt> | |||
| 209 | - | <dd>close a dialog, leave a field</dd> | |||
| 210 | - | </div> | |||
| 211 | - | </dl> | |||
| 212 | - | </dialog> | |||
| 213 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |||
| 214 | - | <div class="panel-head"> | |||
| 215 | - | <label for="cmd-q" class="sr">go to</label> | |||
| 216 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 217 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 218 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 219 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 220 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 221 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 222 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 223 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 224 | - | <kbd>esc</kbd> | |||
| 225 | - | </button> | |||
| 226 | - | </div> | |||
| 227 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 228 | - | <li data-k="go home" data-cursor> | |||
| 229 | - | <a href="/" tabindex="-1"> | |||
| 230 | - | <span class="lbl">home</span> | |||
| 231 | - | <span class="k">go</span> | |||
| 232 | - | </a> | |||
| 233 | - | </li> | |||
| 234 | - | <li data-k="repo demo"> | |||
| 235 | - | <a href="/demo" tabindex="-1"> | |||
| 236 | - | <span class="lbl">demo</span> | |||
| 237 | - | <span class="k">repo</span> | |||
| 238 | - | </a> | |||
| 239 | - | </li> | |||
| 240 | - | <li data-k="repo quiet"> | |||
| 241 | - | <a href="/quiet" tabindex="-1"> | |||
| 242 | - | <span class="lbl">quiet</span> | |||
| 243 | - | <span class="k">repo</span> | |||
| 244 | - | </a> | |||
| 245 | - | </li> | |||
| 246 | - | </ul> | |||
| 247 | - | <p class="panel-foot"> | |||
| 248 | - | <span> | |||
| 249 | - | <kbd>↑</kbd> | |||
| 250 | - | <kbd>↓</kbd> move</span> | |||
| 251 | - | <span> | |||
| 252 | - | <kbd>enter</kbd> open</span> | |||
| 253 | - | <span> | |||
| 254 | - | <kbd>esc</kbd> close</span> | |||
| 255 | - | </p> | |||
| 256 | - | </dialog> | |||
| 257 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |||
| 258 | - | if (q('dialog[open]').count) return; | |||
| 259 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 260 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 261 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 262 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 263 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 264 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 265 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 266 | - | })(1); | |||
| 267 | - | k-k from:body -> (async (d) => { | |||
| 268 | - | if (q('dialog[open]').count) return; | |||
| 269 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 270 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 271 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 272 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 273 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 274 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 275 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 276 | - | })(-1); | |||
| 277 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 278 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 279 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 280 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 281 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 282 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 283 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 284 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 285 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 286 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 287 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 288 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 289 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 290 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 291 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 292 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 293 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 294 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 295 | - | k-next from:body -> { | |||
| 296 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 297 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 298 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 299 | - | }; | |||
| 300 | - | k-split from:body -> { | |||
| 301 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 302 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 303 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 304 | - | }; | |||
| 305 | - | k-unified from:body -> { | |||
| 306 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 307 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 308 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 309 | - | }; | |||
| 310 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 311 | - | k-permalink from:body -> { | |||
| 312 | - | let b = document.body.dataset; | |||
| 313 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 314 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 315 | - | navigator.clipboard?.writeText(location.href); | |||
| 316 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 317 | - | }"> | |||
| 318 | - | </div> | |||
| 319 | - | </body> | |||
| 320 | - | </html> | |||
D cmd/ermite/testdata/web/not-found-page.html +0 -267
| @@ -1,267 +0,0 @@ | |||||
| 1 | - | <!doctype html> | |||
| 2 | - | <html lang="en"> | |||
| 3 | - | <head> | |||
| 4 | - | <meta charset="utf-8"> | |||
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | - | <meta name="color-scheme" content="light dark"> | |||
| 7 | - | <title>404 · example.test</title> | |||
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 12 | - | </script> | |||
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 14 | - | </script> | |||
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 16 | - | </script> | |||
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 18 | - | </script> | |||
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | - | </script> | |||
| 21 | - | </head> | |||
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 23 | - | <a class="skip" href="#main">skip to content</a> | |||
| 24 | - | <header class="hdr"> | |||
| 25 | - | <div class="hdr-top"> | |||
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |||
| 28 | - | </span> | |||
| 29 | - | <span class="brand-name">example.test</span> | |||
| 30 | - | </a> | |||
| 31 | - | <nav class="crumbs" aria-label="Path"> | |||
| 32 | - | <ol> | |||
| 33 | - | <li> | |||
| 34 | - | <span aria-current="page">not found</span> | |||
| 35 | - | </li> | |||
| 36 | - | </ol> | |||
| 37 | - | </nav> | |||
| 38 | - | <div class="hdr-acts"> | |||
| 39 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 40 | - | <span class="ico i-search" aria-hidden="true"> | |||
| 41 | - | </span> | |||
| 42 | - | <span class="search-label">Go to…</span> | |||
| 43 | - | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 44 | - | </button> | |||
| 45 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |||
| 46 | - | </div> | |||
| 47 | - | </div> | |||
| 48 | - | </header> | |||
| 49 | - | <div class="work"> | |||
| 50 | - | <main id="main" tabindex="-1"> | |||
| 51 | - | <section class="state" aria-labelledby="state-title"> | |||
| 52 | - | <p class="eyebrow">404</p> | |||
| 53 | - | <h1 id="state-title">not found</h1> | |||
| 54 | - | <p class="dim">no such page, ref or path.</p> | |||
| 55 | - | <p class="dim">check the URL, or start from the repo list.</p> | |||
| 56 | - | <p class="state-back"> | |||
| 57 | - | <a href="/">home</a> | |||
| 58 | - | </p> | |||
| 59 | - | </section> | |||
| 60 | - | </main> | |||
| 61 | - | </div> | |||
| 62 | - | <footer class="foot"> | |||
| 63 | - | <span>ermite devel</span> | |||
| 64 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 65 | - | <span class="foot-keys"> | |||
| 66 | - | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 67 | - | </footer> | |||
| 68 | - | <div class="hint" role="status" aria-live="polite"> | |||
| 69 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 70 | - | </span> | |||
| 71 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 72 | - | </span> | |||
| 73 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 74 | - | </span> | |||
| 75 | - | </div> | |||
| 76 | - | <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()"> | |||
| 77 | - | <div class="panel-head"> | |||
| 78 | - | <h2>Keyboard shortcuts</h2> | |||
| 79 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 80 | - | <kbd>esc</kbd> close</button> | |||
| 81 | - | </div> | |||
| 82 | - | <dl class="keys"> | |||
| 83 | - | <div> | |||
| 84 | - | <dt> | |||
| 85 | - | <kbd>ctrl k</kbd> | |||
| 86 | - | <kbd>:</kbd> | |||
| 87 | - | </dt> | |||
| 88 | - | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 89 | - | </div> | |||
| 90 | - | <div> | |||
| 91 | - | <dt> | |||
| 92 | - | <kbd>?</kbd> | |||
| 93 | - | </dt> | |||
| 94 | - | <dd>this list</dd> | |||
| 95 | - | </div> | |||
| 96 | - | <div> | |||
| 97 | - | <dt> | |||
| 98 | - | <kbd>j</kbd> | |||
| 99 | - | <kbd>k</kbd> | |||
| 100 | - | </dt> | |||
| 101 | - | <dd>move the row cursor down / up</dd> | |||
| 102 | - | </div> | |||
| 103 | - | <div> | |||
| 104 | - | <dt> | |||
| 105 | - | <kbd>enter</kbd> | |||
| 106 | - | </dt> | |||
| 107 | - | <dd>open the row under the cursor</dd> | |||
| 108 | - | </div> | |||
| 109 | - | <div> | |||
| 110 | - | <dt> | |||
| 111 | - | <kbd>g g</kbd> | |||
| 112 | - | <kbd>G</kbd> | |||
| 113 | - | </dt> | |||
| 114 | - | <dd>first / last row</dd> | |||
| 115 | - | </div> | |||
| 116 | - | <div> | |||
| 117 | - | <dt> | |||
| 118 | - | <kbd>/</kbd> | |||
| 119 | - | </dt> | |||
| 120 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 121 | - | </div> | |||
| 122 | - | <div> | |||
| 123 | - | <dt> | |||
| 124 | - | <kbd>b</kbd> | |||
| 125 | - | </dt> | |||
| 126 | - | <dd>switch branch or tag</dd> | |||
| 127 | - | </div> | |||
| 128 | - | <div> | |||
| 129 | - | <dt> | |||
| 130 | - | <kbd>s</kbd> | |||
| 131 | - | <kbd>t</kbd> | |||
| 132 | - | <kbd>c</kbd> | |||
| 133 | - | <kbd>r</kbd> | |||
| 134 | - | <kbd>m</kbd> | |||
| 135 | - | </dt> | |||
| 136 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 137 | - | </div> | |||
| 138 | - | <div> | |||
| 139 | - | <dt> | |||
| 140 | - | <kbd>g h</kbd> | |||
| 141 | - | </dt> | |||
| 142 | - | <dd>home</dd> | |||
| 143 | - | </div> | |||
| 144 | - | <div> | |||
| 145 | - | <dt> | |||
| 146 | - | <kbd>y</kbd> | |||
| 147 | - | </dt> | |||
| 148 | - | <dd>copy a permalink pinned to the commit</dd> | |||
| 149 | - | </div> | |||
| 150 | - | <div> | |||
| 151 | - | <dt> | |||
| 152 | - | <kbd>d</kbd> | |||
| 153 | - | <kbd>u</kbd> | |||
| 154 | - | <kbd>x</kbd> | |||
| 155 | - | </dt> | |||
| 156 | - | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 157 | - | </div> | |||
| 158 | - | <div> | |||
| 159 | - | <dt> | |||
| 160 | - | <kbd>n</kbd> | |||
| 161 | - | </dt> | |||
| 162 | - | <dd>merge request: next note in the diff</dd> | |||
| 163 | - | </div> | |||
| 164 | - | <div> | |||
| 165 | - | <dt> | |||
| 166 | - | <kbd>esc</kbd> | |||
| 167 | - | </dt> | |||
| 168 | - | <dd>close a dialog, leave a field</dd> | |||
| 169 | - | </div> | |||
| 170 | - | </dl> | |||
| 171 | - | </dialog> | |||
| 172 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |||
| 173 | - | <div class="panel-head"> | |||
| 174 | - | <label for="cmd-q" class="sr">go to</label> | |||
| 175 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 176 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 177 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 178 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 179 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 180 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 181 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 182 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 183 | - | <kbd>esc</kbd> | |||
| 184 | - | </button> | |||
| 185 | - | </div> | |||
| 186 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 187 | - | <li data-k="go home" data-cursor> | |||
| 188 | - | <a href="/" tabindex="-1"> | |||
| 189 | - | <span class="lbl">home</span> | |||
| 190 | - | <span class="k">go</span> | |||
| 191 | - | </a> | |||
| 192 | - | </li> | |||
| 193 | - | </ul> | |||
| 194 | - | <p class="panel-foot"> | |||
| 195 | - | <span> | |||
| 196 | - | <kbd>↑</kbd> | |||
| 197 | - | <kbd>↓</kbd> move</span> | |||
| 198 | - | <span> | |||
| 199 | - | <kbd>enter</kbd> open</span> | |||
| 200 | - | <span> | |||
| 201 | - | <kbd>esc</kbd> close</span> | |||
| 202 | - | </p> | |||
| 203 | - | </dialog> | |||
| 204 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |||
| 205 | - | if (q('dialog[open]').count) return; | |||
| 206 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 207 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 208 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 209 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 210 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 211 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 212 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 213 | - | })(1); | |||
| 214 | - | k-k from:body -> (async (d) => { | |||
| 215 | - | if (q('dialog[open]').count) return; | |||
| 216 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 217 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 218 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 219 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 220 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 221 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 222 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 223 | - | })(-1); | |||
| 224 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 225 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 226 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 227 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 228 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 229 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 230 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 231 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 232 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 233 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 234 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 235 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 236 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 237 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 238 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 239 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 240 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 241 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 242 | - | k-next from:body -> { | |||
| 243 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 244 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 245 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 246 | - | }; | |||
| 247 | - | k-split from:body -> { | |||
| 248 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 249 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 250 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 251 | - | }; | |||
| 252 | - | k-unified from:body -> { | |||
| 253 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 254 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 255 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 256 | - | }; | |||
| 257 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 258 | - | k-permalink from:body -> { | |||
| 259 | - | let b = document.body.dataset; | |||
| 260 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 261 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 262 | - | navigator.clipboard?.writeText(location.href); | |||
| 263 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 264 | - | }"> | |||
| 265 | - | </div> | |||
| 266 | - | </body> | |||
| 267 | - | </html> | |||
D cmd/ermite/testdata/web/not-found.html +0 -267
| @@ -1,267 +0,0 @@ | |||||
| 1 | - | <!doctype html> | |||
| 2 | - | <html lang="en"> | |||
| 3 | - | <head> | |||
| 4 | - | <meta charset="utf-8"> | |||
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | - | <meta name="color-scheme" content="light dark"> | |||
| 7 | - | <title>404 · example.test</title> | |||
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 12 | - | </script> | |||
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 14 | - | </script> | |||
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 16 | - | </script> | |||
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 18 | - | </script> | |||
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | - | </script> | |||
| 21 | - | </head> | |||
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 23 | - | <a class="skip" href="#main">skip to content</a> | |||
| 24 | - | <header class="hdr"> | |||
| 25 | - | <div class="hdr-top"> | |||
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |||
| 28 | - | </span> | |||
| 29 | - | <span class="brand-name">example.test</span> | |||
| 30 | - | </a> | |||
| 31 | - | <nav class="crumbs" aria-label="Path"> | |||
| 32 | - | <ol> | |||
| 33 | - | <li> | |||
| 34 | - | <span aria-current="page">not found</span> | |||
| 35 | - | </li> | |||
| 36 | - | </ol> | |||
| 37 | - | </nav> | |||
| 38 | - | <div class="hdr-acts"> | |||
| 39 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 40 | - | <span class="ico i-search" aria-hidden="true"> | |||
| 41 | - | </span> | |||
| 42 | - | <span class="search-label">Go to…</span> | |||
| 43 | - | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 44 | - | </button> | |||
| 45 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |||
| 46 | - | </div> | |||
| 47 | - | </div> | |||
| 48 | - | </header> | |||
| 49 | - | <div class="work"> | |||
| 50 | - | <main id="main" tabindex="-1"> | |||
| 51 | - | <section class="state" aria-labelledby="state-title"> | |||
| 52 | - | <p class="eyebrow">404</p> | |||
| 53 | - | <h1 id="state-title">not found</h1> | |||
| 54 | - | <p class="dim">no such page, ref or path.</p> | |||
| 55 | - | <p class="dim">check the URL, or start from the repo list.</p> | |||
| 56 | - | <p class="state-back"> | |||
| 57 | - | <a href="/">home</a> | |||
| 58 | - | </p> | |||
| 59 | - | </section> | |||
| 60 | - | </main> | |||
| 61 | - | </div> | |||
| 62 | - | <footer class="foot"> | |||
| 63 | - | <span>ermite devel</span> | |||
| 64 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 65 | - | <span class="foot-keys"> | |||
| 66 | - | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 67 | - | </footer> | |||
| 68 | - | <div class="hint" role="status" aria-live="polite"> | |||
| 69 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 70 | - | </span> | |||
| 71 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 72 | - | </span> | |||
| 73 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 74 | - | </span> | |||
| 75 | - | </div> | |||
| 76 | - | <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()"> | |||
| 77 | - | <div class="panel-head"> | |||
| 78 | - | <h2>Keyboard shortcuts</h2> | |||
| 79 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 80 | - | <kbd>esc</kbd> close</button> | |||
| 81 | - | </div> | |||
| 82 | - | <dl class="keys"> | |||
| 83 | - | <div> | |||
| 84 | - | <dt> | |||
| 85 | - | <kbd>ctrl k</kbd> | |||
| 86 | - | <kbd>:</kbd> | |||
| 87 | - | </dt> | |||
| 88 | - | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 89 | - | </div> | |||
| 90 | - | <div> | |||
| 91 | - | <dt> | |||
| 92 | - | <kbd>?</kbd> | |||
| 93 | - | </dt> | |||
| 94 | - | <dd>this list</dd> | |||
| 95 | - | </div> | |||
| 96 | - | <div> | |||
| 97 | - | <dt> | |||
| 98 | - | <kbd>j</kbd> | |||
| 99 | - | <kbd>k</kbd> | |||
| 100 | - | </dt> | |||
| 101 | - | <dd>move the row cursor down / up</dd> | |||
| 102 | - | </div> | |||
| 103 | - | <div> | |||
| 104 | - | <dt> | |||
| 105 | - | <kbd>enter</kbd> | |||
| 106 | - | </dt> | |||
| 107 | - | <dd>open the row under the cursor</dd> | |||
| 108 | - | </div> | |||
| 109 | - | <div> | |||
| 110 | - | <dt> | |||
| 111 | - | <kbd>g g</kbd> | |||
| 112 | - | <kbd>G</kbd> | |||
| 113 | - | </dt> | |||
| 114 | - | <dd>first / last row</dd> | |||
| 115 | - | </div> | |||
| 116 | - | <div> | |||
| 117 | - | <dt> | |||
| 118 | - | <kbd>/</kbd> | |||
| 119 | - | </dt> | |||
| 120 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 121 | - | </div> | |||
| 122 | - | <div> | |||
| 123 | - | <dt> | |||
| 124 | - | <kbd>b</kbd> | |||
| 125 | - | </dt> | |||
| 126 | - | <dd>switch branch or tag</dd> | |||
| 127 | - | </div> | |||
| 128 | - | <div> | |||
| 129 | - | <dt> | |||
| 130 | - | <kbd>s</kbd> | |||
| 131 | - | <kbd>t</kbd> | |||
| 132 | - | <kbd>c</kbd> | |||
| 133 | - | <kbd>r</kbd> | |||
| 134 | - | <kbd>m</kbd> | |||
| 135 | - | </dt> | |||
| 136 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 137 | - | </div> | |||
| 138 | - | <div> | |||
| 139 | - | <dt> | |||
| 140 | - | <kbd>g h</kbd> | |||
| 141 | - | </dt> | |||
| 142 | - | <dd>home</dd> | |||
| 143 | - | </div> | |||
| 144 | - | <div> | |||
| 145 | - | <dt> | |||
| 146 | - | <kbd>y</kbd> | |||
| 147 | - | </dt> | |||
| 148 | - | <dd>copy a permalink pinned to the commit</dd> | |||
| 149 | - | </div> | |||
| 150 | - | <div> | |||
| 151 | - | <dt> | |||
| 152 | - | <kbd>d</kbd> | |||
| 153 | - | <kbd>u</kbd> | |||
| 154 | - | <kbd>x</kbd> | |||
| 155 | - | </dt> | |||
| 156 | - | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 157 | - | </div> | |||
| 158 | - | <div> | |||
| 159 | - | <dt> | |||
| 160 | - | <kbd>n</kbd> | |||
| 161 | - | </dt> | |||
| 162 | - | <dd>merge request: next note in the diff</dd> | |||
| 163 | - | </div> | |||
| 164 | - | <div> | |||
| 165 | - | <dt> | |||
| 166 | - | <kbd>esc</kbd> | |||
| 167 | - | </dt> | |||
| 168 | - | <dd>close a dialog, leave a field</dd> | |||
| 169 | - | </div> | |||
| 170 | - | </dl> | |||
| 171 | - | </dialog> | |||
| 172 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |||
| 173 | - | <div class="panel-head"> | |||
| 174 | - | <label for="cmd-q" class="sr">go to</label> | |||
| 175 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 176 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 177 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 178 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 179 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 180 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 181 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 182 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 183 | - | <kbd>esc</kbd> | |||
| 184 | - | </button> | |||
| 185 | - | </div> | |||
| 186 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 187 | - | <li data-k="go home" data-cursor> | |||
| 188 | - | <a href="/" tabindex="-1"> | |||
| 189 | - | <span class="lbl">home</span> | |||
| 190 | - | <span class="k">go</span> | |||
| 191 | - | </a> | |||
| 192 | - | </li> | |||
| 193 | - | </ul> | |||
| 194 | - | <p class="panel-foot"> | |||
| 195 | - | <span> | |||
| 196 | - | <kbd>↑</kbd> | |||
| 197 | - | <kbd>↓</kbd> move</span> | |||
| 198 | - | <span> | |||
| 199 | - | <kbd>enter</kbd> open</span> | |||
| 200 | - | <span> | |||
| 201 | - | <kbd>esc</kbd> close</span> | |||
| 202 | - | </p> | |||
| 203 | - | </dialog> | |||
| 204 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |||
| 205 | - | if (q('dialog[open]').count) return; | |||
| 206 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 207 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 208 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 209 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 210 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 211 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 212 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 213 | - | })(1); | |||
| 214 | - | k-k from:body -> (async (d) => { | |||
| 215 | - | if (q('dialog[open]').count) return; | |||
| 216 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 217 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 218 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 219 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |||
| 220 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |||
| 221 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |||
| 222 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |||
| 223 | - | })(-1); | |||
| 224 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 225 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 226 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 227 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 228 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 229 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 230 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 231 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 232 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 233 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 234 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 235 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 236 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 237 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 238 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 239 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 240 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 241 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 242 | - | k-next from:body -> { | |||
| 243 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 244 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 245 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 246 | - | }; | |||
| 247 | - | k-split from:body -> { | |||
| 248 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 249 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 250 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 251 | - | }; | |||
| 252 | - | k-unified from:body -> { | |||
| 253 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 254 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 255 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 256 | - | }; | |||
| 257 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 258 | - | k-permalink from:body -> { | |||
| 259 | - | let b = document.body.dataset; | |||
| 260 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 261 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 262 | - | navigator.clipboard?.writeText(location.href); | |||
| 263 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 264 | - | }"> | |||
| 265 | - | </div> | |||
| 266 | - | </body> | |||
| 267 | - | </html> | |||
M internal/adapters/ermitegit/cache_test.go +109 -35
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "path/filepath" | 11 | "path/filepath" | ||
| 12 | "sync" | 12 | "sync" | ||
| 13 | "testing" | 13 | "testing" | ||
| 14 | + | "testing/synctest" | |||
| 14 | "time" | 15 | "time" | ||
| 15 | 16 | ||||
| 16 | "github.com/bnema/zerowrap" | 17 | "github.com/bnema/zerowrap" | ||
| @@ -207,56 +208,129 @@ func TestEncodedCommitIsServedByGit(t *testing.T) { | |||||
| 207 | assert.Equal(t, "café", got.Commits[0].Message) | 208 | assert.Equal(t, "café", got.Commits[0].Message) | ||
| 208 | } | 209 | } | ||
| 209 | 210 | ||||
| 211 | + | // The idle tests run in a synctest bubble: the clock is fake, so IdleTTL is the | |||
| 212 | + | // production one and the boundaries are exact. The sweeper ticks every | |||
| 213 | + | // IdleTTL/4 from the creation of the adapter and closes what has been idle for | |||
| 214 | + | // more than IdleTTL, so a repository last used at t is closed at the first tick | |||
| 215 | + | // after t+IdleTTL, which is t+IdleTTL+IdleTTL/4 here (no time passes while the | |||
| 216 | + | // fixtures are built: the bubble's clock only moves when everything sleeps). | |||
| 217 | + | const idleTTL = ermitegit.DefaultIdleTTL | |||
| 218 | + | ||||
| 210 | // A repository that nobody reads for IdleTTL is closed, without a goroutine | 219 | // A repository that nobody reads for IdleTTL is closed, without a goroutine | ||
| 211 | // per repository. | 220 | // per repository. | ||
| 212 | func TestIdleRepositoriesAreClosed(t *testing.T) { | 221 | func TestIdleRepositoriesAreClosed(t *testing.T) { | ||
| 213 | - | ctx := context.Background() | 222 | + | synctest.Test(t, func(t *testing.T) { |
| 214 | - | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond }) | 223 | + | ctx := context.Background() |
| 215 | - | f := commitOf(t, data, "idle") | 224 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) |
| 216 | - | _, err := a.ListRefs(ctx, f.Name) | 225 | + | f := commitOf(t, data, "idle") |
| 217 | - | require.NoError(t, err) | 226 | + | _, err := a.ListRefs(ctx, f.Name) |
| 218 | - | require.Equal(t, 1, a.OpenRepos()) | 227 | + | require.NoError(t, err) |
| 219 | - | require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond) | 228 | + | require.Equal(t, 1, a.OpenRepos()) |
| 220 | 229 | ||||
| 221 | - | // It opens again on the next read. | 230 | + | synctest.Sleep(idleTTL) // idle for exactly the TTL: not more |
| 222 | - | _, err = a.ListRefs(ctx, f.Name) | 231 | + | assert.Equal(t, 1, a.OpenRepos(), "not idle for more than the TTL yet") |
| 223 | - | require.NoError(t, err) | 232 | + | synctest.Sleep(idleTTL/4 - time.Nanosecond) |
| 224 | - | assert.Equal(t, 1, a.OpenRepos()) | 233 | + | assert.Equal(t, 1, a.OpenRepos(), "the sweeper has not ticked yet") |
| 234 | + | synctest.Sleep(time.Nanosecond) | |||
| 235 | + | assert.Equal(t, 0, a.OpenRepos(), "closed by the first sweep after the TTL") | |||
| 236 | + | ||||
| 237 | + | // It opens again on the next read. | |||
| 238 | + | _, err = a.ListRefs(ctx, f.Name) | |||
| 239 | + | require.NoError(t, err) | |||
| 240 | + | assert.Equal(t, 1, a.OpenRepos()) | |||
| 241 | + | }) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // A read keeps the repository open: the TTL counts from the last use. | |||
| 245 | + | func TestReadsKeepARepositoryOpen(t *testing.T) { | |||
| 246 | + | synctest.Test(t, func(t *testing.T) { | |||
| 247 | + | ctx := context.Background() | |||
| 248 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) | |||
| 249 | + | f := commitOf(t, data, "busy") | |||
| 250 | + | for range 10 { | |||
| 251 | + | _, err := a.ListRefs(ctx, f.Name) | |||
| 252 | + | require.NoError(t, err) | |||
| 253 | + | synctest.Sleep(idleTTL / 2) | |||
| 254 | + | require.Equal(t, 1, a.OpenRepos(), "used within the TTL") | |||
| 255 | + | } | |||
| 256 | + | synctest.Sleep(idleTTL) | |||
| 257 | + | assert.Equal(t, 0, a.OpenRepos(), "closed once nobody reads it") | |||
| 258 | + | }) | |||
| 225 | } | 259 | } | ||
| 226 | 260 | ||||
| 227 | // A Refresh after a write is not a read: a repository pushed to but never read | 261 | // A Refresh after a write is not a read: a repository pushed to but never read | ||
| 228 | // still closes after IdleTTL. | 262 | // still closes after IdleTTL. | ||
| 229 | func TestRefreshIsNotAUse(t *testing.T) { | 263 | func TestRefreshIsNotAUse(t *testing.T) { | ||
| 230 | - | ctx := context.Background() | 264 | + | synctest.Test(t, func(t *testing.T) { |
| 231 | - | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond }) | 265 | + | ctx := context.Background() |
| 232 | - | f := commitOf(t, data, "pushed") | 266 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) |
| 233 | - | _, err := a.ListRefs(ctx, f.Name) | 267 | + | f := commitOf(t, data, "pushed") |
| 234 | - | require.NoError(t, err) | 268 | + | _, err := a.ListRefs(ctx, f.Name) |
| 235 | - | start := time.Now() | 269 | + | require.NoError(t, err) |
| 236 | - | for time.Since(start) < 5*time.Second && a.OpenRepos() == 1 { | 270 | + | // A push every 30 seconds, until the sweep that follows the TTL. |
| 237 | - | a.Refresh(ctx, f.Name) | 271 | + | for elapsed := time.Duration(0); elapsed < idleTTL+idleTTL/4; elapsed += 30 * time.Second { |
| 238 | - | time.Sleep(20 * time.Millisecond) | 272 | + | require.Equal(t, 1, a.OpenRepos(), "open after %v", elapsed) |
| 239 | - | } | 273 | + | a.Refresh(ctx, f.Name) |
| 240 | - | assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open") | 274 | + | synctest.Sleep(30 * time.Second) |
| 275 | + | } | |||
| 276 | + | assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open") | |||
| 277 | + | }) | |||
| 241 | } | 278 | } | ||
| 242 | 279 | ||||
| 243 | // The idle close does not cut a blob stream the reader still holds: the library | 280 | // The idle close does not cut a blob stream the reader still holds: the library | ||
| 244 | // keeps the stream valid after Close. | 281 | // keeps the stream valid after Close. | ||
| 245 | func TestIdleCloseDoesNotCutAStream(t *testing.T) { | 282 | func TestIdleCloseDoesNotCutAStream(t *testing.T) { | ||
| 246 | - | ctx := context.Background() | 283 | + | synctest.Test(t, func(t *testing.T) { |
| 247 | - | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 50 * time.Millisecond }) | 284 | + | ctx := context.Background() |
| 248 | - | f := readertest.NewFixture(t, data, "stream") | 285 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) |
| 249 | - | content := readertest.RandomBytes(3<<20, 12) | 286 | + | f := readertest.NewFixture(t, data, "stream") |
| 250 | - | f.WriteBytes("big.bin", content) | 287 | + | content := readertest.RandomBytes(3<<20, 12) |
| 251 | - | f.Commit("big") | 288 | + | f.WriteBytes("big.bin", content) |
| 289 | + | f.Commit("big") | |||
| 290 | + | ||||
| 291 | + | _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin")) | |||
| 292 | + | require.NoError(t, err) | |||
| 293 | + | synctest.Sleep(idleTTL + idleTTL/4) | |||
| 294 | + | require.Equal(t, 0, a.OpenRepos()) | |||
| 295 | + | got, err := io.ReadAll(rc) | |||
| 296 | + | require.NoError(t, err) | |||
| 297 | + | require.NoError(t, rc.Close()) | |||
| 298 | + | assert.True(t, bytes.Equal(content, got)) | |||
| 299 | + | }) | |||
| 300 | + | } | |||
| 252 | 301 | ||||
| 253 | - | _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin")) | 302 | + | // A repository ermite-git could not read is served by git for UnsupportedRetry, |
| 254 | - | require.NoError(t, err) | 303 | + | // then opened again: a repack, or the removal of a replace ref, can make it |
| 255 | - | require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond) | 304 | + | // readable. The commit-graph of the history fixture is mapped only while the |
| 256 | - | got, err := io.ReadAll(rc) | 305 | + | // library has the repository open. |
| 257 | - | require.NoError(t, err) | 306 | + | func TestUnsupportedRepositoryIsTriedAgainAfterTheRetryDelay(t *testing.T) { |
| 258 | - | require.NoError(t, rc.Close()) | 307 | + | synctest.Test(t, func(t *testing.T) { |
| 259 | - | assert.True(t, bytes.Equal(content, got)) | 308 | + | ctx := context.Background() |
| 309 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 310 | + | h := readertest.NewHistory(t, data) | |||
| 311 | + | h.Maintain() | |||
| 312 | + | alternates := filepath.Join(h.Bare, "objects", "info", "alternates") | |||
| 313 | + | require.NoError(t, os.WriteFile(alternates, []byte(filepath.Join(t.TempDir(), "objects")+"\n"), 0o644)) | |||
| 314 | + | read := func() { | |||
| 315 | + | t.Helper() | |||
| 316 | + | // ListRefs: git's answer is a one-shot command, where ListCommits | |||
| 317 | + | // would start a long-lived cat-file process, whose pipes keep the | |||
| 318 | + | // bubble from ever being idle. | |||
| 319 | + | _, err := a.ListRefs(ctx, h.Name) | |||
| 320 | + | require.NoError(t, err) | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | read() | |||
| 324 | + | require.False(t, mapped(t, h.Bare), "unsupported: served by git") | |||
| 325 | + | require.NoError(t, os.Remove(alternates)) // readable now | |||
| 326 | + | ||||
| 327 | + | synctest.Sleep(ermitegit.UnsupportedRetry) | |||
| 328 | + | read() | |||
| 329 | + | assert.False(t, mapped(t, h.Bare), "still served by git: the delay is not over") | |||
| 330 | + | synctest.Sleep(time.Nanosecond) | |||
| 331 | + | read() | |||
| 332 | + | assert.True(t, mapped(t, h.Bare), "tried again once the delay is over") | |||
| 333 | + | }) | |||
| 260 | } | 334 | } | ||
| 261 | 335 | ||||
| 262 | // mapped reports whether the process maps a file under dir (Linux). | 336 | // mapped reports whether the process maps a file under dir (Linux). | ||
M internal/adapters/ermitegit/ermitegit.go +20 -8
| @@ -87,9 +87,13 @@ type Routes struct { | |||||
| 87 | ResolveRefPath Backend | 87 | ResolveRefPath Backend | ||
| 88 | // GetTree is a directory listing without last commits. | 88 | // GetTree is a directory listing without last commits. | ||
| 89 | GetTree Backend | 89 | GetTree Backend | ||
| 90 | - | // GetTreeLastCommits is a listing with the last commit of each entry. | 90 | + | // GetTreeLastCommits is the listing of the root with the last commit of |
| 91 | + | // each entry. | |||
| 91 | GetTreeLastCommits Backend | 92 | GetTreeLastCommits Backend | ||
| 92 | - | OpenBlob Backend | 93 | + | // GetTreeSubdirLastCommits is the same for a subdirectory, which costs a |
| 94 | + | // much longer history walk (195 ms with git on lazygit). | |||
| 95 | + | GetTreeSubdirLastCommits Backend | |||
| 96 | + | OpenBlob Backend | |||
| 93 | // ListCommits is a page of history of a revision. | 97 | // ListCommits is a page of history of a revision. | ||
| 94 | ListCommits Backend | 98 | ListCommits Backend | ||
| 95 | // ListCommitsPath is a page of the history of a file or a directory. | 99 | // ListCommitsPath is a page of the history of a file or a directory. | ||
| @@ -98,10 +102,22 @@ type Routes struct { | |||||
| 98 | ListPaths Backend | 102 | ListPaths Backend | ||
| 99 | } | 103 | } | ||
| 100 | 104 | ||||
| 105 | + | // tree picks the route of a directory listing. | |||
| 106 | + | func (r Routes) tree(path domain.TreePath, lastCommits bool) Backend { | |||
| 107 | + | switch { | |||
| 108 | + | case !lastCommits: | |||
| 109 | + | return r.GetTree | |||
| 110 | + | case path.IsRoot(): | |||
| 111 | + | return r.GetTreeLastCommits | |||
| 112 | + | default: | |||
| 113 | + | return r.GetTreeSubdirLastCommits | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 101 | // AllRoutes returns routes that send every method to b. The tests and the | 117 | // AllRoutes returns routes that send every method to b. The tests and the | ||
| 102 | // benchmarks use it to exercise a backend whatever the production routes are. | 118 | // benchmarks use it to exercise a backend whatever the production routes are. | ||
| 103 | func AllRoutes(b Backend) Routes { | 119 | func AllRoutes(b Backend) Routes { | ||
| 104 | - | 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} |
| 105 | } | 121 | } | ||
| 106 | 122 | ||||
| 107 | // Config configures an Adapter. | 123 | // Config configures an Adapter. | ||
| @@ -335,11 +351,7 @@ func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domai | |||||
| 335 | // GetTree lists a directory. | 351 | // GetTree lists a directory. | ||
| 336 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | 352 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | ||
| 337 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | 353 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | ||
| 338 | - | route := a.routes.GetTree | 354 | + | if a.routes.tree(path, includeLastCommit) == CLI { |
| 339 | - | if includeLastCommit { | |||
| 340 | - | route = a.routes.GetTreeLastCommits | |||
| 341 | - | } | |||
| 342 | - | if route == CLI { | |||
| 343 | return cli() | 355 | return cli() | ||
| 344 | } | 356 | } | ||
| 345 | return serve(ctx, a, repo, func(r *git.Repo) (domain.Tree, error) { | 357 | return serve(ctx, a, repo, func(r *git.Repo) (domain.Tree, error) { | ||
A internal/adapters/ermitegit/export_test.go +6 -0
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit | |||
| 4 | + | ||||
| 5 | + | // UnsupportedRetry exposes unsupportedRetry to the tests. | |||
| 6 | + | const UnsupportedRetry = unsupportedRetry | |||
M internal/adapters/ermitegit/routes.go +58 -46
| @@ -4,16 +4,20 @@ package ermitegit | |||||
| 4 | 4 | ||||
| 5 | // DefaultRoutes returns the routes of production. | 5 | // DefaultRoutes returns the routes of production. | ||
| 6 | // | 6 | // | ||
| 7 | - | // The rule: a method is routed to ermite-git only when it passes the shared | 7 | + | // The rule: a method is routed to ermite-git when it passes the shared |
| 8 | // conformance table (TestConformance, TestCompareWithCLI) and its benchmark | 8 | // conformance table (TestConformance, TestCompareWithCLI) and its benchmark | ||
| 9 | - | // beats the CLI adapter in time, in bytes AND in allocations per call, warm | 9 | + | // beats the CLI adapter WARM in time AND in bytes per call, on the generated |
| 10 | - | // AND cold, on the generated fixture AND on lazygit (BenchmarkRepoReader, | 10 | + | // fixture AND on lazygit (BenchmarkRepoReader, ERMITE_GIT_BENCH_REPO), and its |
| 11 | - | // ERMITE_GIT_BENCH_REPO). Where a method has several shapes, every shape must | 11 | + | // COLD run does not lose in time by more than the open itself (about 65 µs, |
| 12 | - | // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and | 12 | + | // paid once per open, see below). Allocation counts are reported but do not |
| 13 | - | // ListCommitsPath cover directories of any size and files of any history. A | 13 | + | // decide: the repository cache (cache.go) keeps up to 64 repositories open for |
| 14 | - | // method that does not win stays on the CLI. GetCommit is not routable: the | 14 | + | // ten minutes, so nearly every request is warm, and the CLI's own cost (a |
| 15 | - | // diff stays on git. ListPaths has one shape: the fixture lists 131 files and | 15 | + | // process per call, absent from the Go counters) is not in its numbers. Where |
| 16 | - | // lazygit 2,180. | 16 | + | // a method has several shapes that behave differently, each has its own |
| 17 | + | // constant: GetTree, GetTreeLastCommits (root), GetTreeSubdirLastCommits, | |||
| 18 | + | // ResolveRevisionAbbrev and ListCommitsPath. A method that does not win stays | |||
| 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. | |||
| 17 | // | 21 | // | ||
| 18 | // Warm is a repository that is open; cold closes it before every call, the | 22 | // Warm is a repository that is open; cold closes it before every call, the | ||
| 19 | // price of the first read after the idle TTL, an eviction or a restart (a | 23 | // price of the first read after the idle TTL, an eviction or a restart (a | ||
| @@ -97,45 +101,53 @@ package ermitegit | |||||
| 97 | // ListCommits/dirpath lazygit warm 6.7ms 127.8K 403 2.0ms 50.2K 181 win | 101 | // ListCommits/dirpath lazygit warm 6.7ms 127.8K 403 2.0ms 50.2K 181 win | ||
| 98 | // ListCommits/dirpath lazygit cold 6.7ms 127.8K 403 3.3ms 887.1K 2019 loses ba | 102 | // ListCommits/dirpath lazygit cold 6.7ms 127.8K 403 3.3ms 887.1K 2019 loses ba | ||
| 99 | // | 103 | // | ||
| 100 | - | // What decides the routes, with ermite-git 5228465 (zlib reader with a reused | 104 | + | // The "verdict" column is the strict reading, every line of the table won; |
| 101 | - | // adler32, header-size cache, Refresh and LookupRefID without allocations): | 105 | + | // the routes follow the rule above. What decides them, with ermite-git 5228465 |
| 106 | + | // (zlib reader with a reused adler32, header-size cache, Refresh and | |||
| 107 | + | // LookupRefID without allocations): | |||
| 102 | // | 108 | // | ||
| 103 | - | // - The warm runs all win but GetTree of a large directory on lazygit (time | 109 | + | // - ListRefs (every repository page, through loadRepo): 905 µs against 24 µs |
| 104 | - | // is won since the zlib change, bytes are not) and OpenBlob, whose | 110 | + | // warm, 120 KiB against 19 KiB (fixture), 960 against 16 µs and 108 KiB |
| 105 | - | // allocations come from the inflate of a small blob and whose time is | 111 | + | // against 2 KiB (lazygit). Cold it is still 4 to 10 times faster |
| 106 | - | // lost for a 10 MiB blob (inflate speed: 52 ms against 21 ms). | 112 | + | // (93 µs); only its allocation count loses. Routed. |
| 107 | - | // - The cold runs lose in allocations for every method that does little | 113 | + | // - ResolveRevision (name, full id, abbreviation) and ResolveRefPath: 4 to 8 |
| 108 | - | // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200 | 114 | + | // times faster warm, with fewer bytes. Cold they lose 45 µs of time to the |
| 109 | - | // against 60 to 140), and in time too for the ones that cost a few | 115 | + | // open, but the ListRefs of the same page has opened the repository |
| 110 | - | // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs). | 116 | + | // already, so the open is paid by a call that wins it back. Routed. |
| 111 | - | // - Only the history of a revision (ListCommits page 1 and deep) and the | 117 | + | // - GetTree with last commits: root listing 4.1 ms against 68 µs on the |
| 112 | - | // recursive path list (ListPaths) win every line, so they are the only | 118 | + | // fixture, 20.5 ms against 6.2 ms on lazygit (cold 8.7 ms; its 4,206 |
| 113 | - | // methods routed. ListPaths wins even cold because one walk replaces a | 119 | + | // allocations against 3,082 are the only line lost); a subdirectory of |
| 114 | - | // process and the parsing of its output (the bytes of the paths are kept in | 120 | + | // lazygit 195-199 ms against 8-9 ms warm, 15-17 ms cold, 20 MiB against |
| 115 | - | // 32 KiB blocks, which is what brought the lazygit bytes under the CLI's: | 121 | + | // 1.5 MiB. They are two constants so that either can be flipped back on |
| 116 | - | // a single growing buffer lost them, 699K against 433K). GetTree dir+last | 122 | + | // its own. Both routed. |
| 117 | - | // also wins every line, but GetTreeLastCommits covers GetTree root+last | 123 | + | // - GetTree without last commits wins warm in time everywhere (13 µs against |
| 118 | - | // too, which loses cold allocations on lazygit (4204 against 3073): it is | 124 | + | // 0.7 ms at the root), but a large directory of lazygit loses warm bytes |
| 119 | - | // the nearest miss, and the first to flip once opening a repository | 125 | + | // (325 KiB against 275 KiB) and, cold, 4.0 ms against 1.4 ms in time. It |
| 120 | - | // allocates less. | 126 | + | // stays on the CLI. |
| 121 | - | // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit, | 127 | + | // - ListCommits of a revision wins every line and stays routed. |
| 122 | - | // warm bytes and allocations; above, it loses time. No size makes it win, | 128 | + | // ListCommitsPath wins warm and cold in time (lazygit file: 12.9 ms against |
| 123 | - | // so there is no size-based route. | 129 | + | // 6.0 ms warm), only its cold bytes lose (2.4 MiB): it is a candidate, not |
| 124 | - | // | 130 | + | // flipped here, to be decided with its own conformance review. |
| 125 | - | // Once opening a repository allocates less, re-run the benchmark and flip the | 131 | + | // - OpenBlob by size: below 1 MiB the small blob loses warm bytes and |
| 126 | - | // constants it wins: ListRefs, ResolveRefPath and ResolveRevision win warm by 4 | 132 | + | // allocations on lazygit; above, it loses time (inflate speed: 52 ms |
| 127 | - | // to 60 times in time. | 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. | |||
| 128 | func DefaultRoutes() Routes { | 139 | func DefaultRoutes() Routes { | ||
| 129 | return Routes{ | 140 | return Routes{ | ||
| 130 | - | ListRefs: CLI, | 141 | + | ListRefs: ErmiteGit, |
| 131 | - | ResolveRevision: CLI, | 142 | + | ResolveRevision: ErmiteGit, |
| 132 | - | ResolveRevisionAbbrev: CLI, | 143 | + | ResolveRevisionAbbrev: ErmiteGit, |
| 133 | - | ResolveRefPath: CLI, | 144 | + | ResolveRefPath: ErmiteGit, |
| 134 | - | GetTree: CLI, | 145 | + | GetTree: CLI, |
| 135 | - | GetTreeLastCommits: CLI, | 146 | + | GetTreeLastCommits: ErmiteGit, |
| 136 | - | OpenBlob: CLI, | 147 | + | GetTreeSubdirLastCommits: ErmiteGit, |
| 137 | - | ListCommits: ErmiteGit, | 148 | + | OpenBlob: CLI, |
| 138 | - | ListCommitsPath: CLI, | 149 | + | ListCommits: ErmiteGit, |
| 139 | - | ListPaths: ErmiteGit, | 150 | + | ListCommitsPath: CLI, |
| 151 | + | ListPaths: ErmiteGit, | |||
| 140 | } | 152 | } | ||
| 141 | } | 153 | } | ||
M internal/adapters/eventbus/bus_test.go +105 -85
| @@ -4,9 +4,10 @@ package eventbus_test | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "runtime" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | - | "time" | 10 | + | "testing/synctest" |
| 10 | 11 | ||||
| 11 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| @@ -19,121 +20,140 @@ func ev(ref string) domain.Event { | |||||
| 19 | return domain.PushEvent{Updates: []domain.RefUpdate{{Ref: ref}}} | 20 | return domain.PushEvent{Updates: []domain.RefUpdate{{Ref: ref}}} | ||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 23 | + | // recv takes the event a subscriber already has. Publish delivers into the | |||
| 24 | + | // buffer before it returns, so there is nothing to wait for. | |||
| 22 | func recv(t *testing.T, ch <-chan domain.Event) domain.Event { | 25 | func recv(t *testing.T, ch <-chan domain.Event) domain.Event { | ||
| 23 | t.Helper() | 26 | t.Helper() | ||
| 27 | + | synctest.Wait() | |||
| 24 | select { | 28 | select { | ||
| 25 | case e, ok := <-ch: | 29 | case e, ok := <-ch: | ||
| 26 | require.True(t, ok, "channel closed") | 30 | require.True(t, ok, "channel closed") | ||
| 27 | return e | 31 | return e | ||
| 28 | - | case <-time.After(2 * time.Second): | 32 | + | default: |
| 29 | t.Fatal("no event") | 33 | t.Fatal("no event") | ||
| 30 | return nil | 34 | return nil | ||
| 31 | } | 35 | } | ||
| 32 | } | 36 | } | ||
| 33 | 37 | ||||
| 34 | func TestFanOut(t *testing.T) { | 38 | func TestFanOut(t *testing.T) { | ||
| 35 | - | ctx, cancel := context.WithCancel(context.Background()) | 39 | + | t.Parallel() |
| 36 | - | defer cancel() | 40 | + | synctest.Test(t, func(t *testing.T) { |
| 37 | - | b := eventbus.New() | 41 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 38 | - | a, c := b.Subscribe(ctx), b.Subscribe(ctx) | 42 | + | defer cancel() |
| 43 | + | b := eventbus.New() | |||
| 44 | + | a, c := b.Subscribe(ctx), b.Subscribe(ctx) | |||
| 39 | 45 | ||||
| 40 | - | b.Publish(ctx, ev("1")) | 46 | + | b.Publish(ctx, ev("1")) |
| 41 | - | b.Publish(ctx, domain.RepoCreated{}) | 47 | + | b.Publish(ctx, domain.RepoCreated{}) |
| 42 | - | assert.Equal(t, ev("1"), recv(t, a)) | 48 | + | assert.Equal(t, ev("1"), recv(t, a)) |
| 43 | - | assert.Equal(t, ev("1"), recv(t, c)) | 49 | + | assert.Equal(t, ev("1"), recv(t, c)) |
| 44 | - | assert.Equal(t, "repo.created", recv(t, a).EventKind()) | 50 | + | assert.Equal(t, "repo.created", recv(t, a).EventKind()) |
| 45 | - | assert.Equal(t, "repo.created", recv(t, c).EventKind()) | 51 | + | assert.Equal(t, "repo.created", recv(t, c).EventKind()) |
| 52 | + | }) | |||
| 46 | } | 53 | } | ||
| 47 | 54 | ||||
| 48 | func TestOnlyEventsFromSubscription(t *testing.T) { | 55 | func TestOnlyEventsFromSubscription(t *testing.T) { | ||
| 49 | - | ctx, cancel := context.WithCancel(context.Background()) | 56 | + | t.Parallel() |
| 50 | - | defer cancel() | 57 | + | synctest.Test(t, func(t *testing.T) { |
| 51 | - | b := eventbus.New() | 58 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 52 | - | b.Publish(ctx, ev("before")) | 59 | + | defer cancel() |
| 53 | - | ch := b.Subscribe(ctx) | 60 | + | b := eventbus.New() |
| 54 | - | b.Publish(ctx, ev("after")) | 61 | + | b.Publish(ctx, ev("before")) |
| 55 | - | assert.Equal(t, ev("after"), recv(t, ch)) | 62 | + | ch := b.Subscribe(ctx) |
| 63 | + | b.Publish(ctx, ev("after")) | |||
| 64 | + | assert.Equal(t, ev("after"), recv(t, ch)) | |||
| 65 | + | synctest.Wait() | |||
| 66 | + | assert.Empty(t, ch, "nothing else is buffered") | |||
| 67 | + | }) | |||
| 56 | } | 68 | } | ||
| 57 | 69 | ||||
| 58 | func TestSlowSubscriberGetsGapAndDoesNotBlock(t *testing.T) { | 70 | func TestSlowSubscriberGetsGapAndDoesNotBlock(t *testing.T) { | ||
| 59 | - | ctx, cancel := context.WithCancel(context.Background()) | 71 | + | t.Parallel() |
| 60 | - | defer cancel() | 72 | + | synctest.Test(t, func(t *testing.T) { |
| 61 | - | b := eventbus.NewWithBuffer(4) | 73 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 62 | - | slow, fast := b.Subscribe(ctx), b.Subscribe(ctx) | 74 | + | defer cancel() |
| 75 | + | b := eventbus.NewWithBuffer(4) | |||
| 76 | + | slow, fast := b.Subscribe(ctx), b.Subscribe(ctx) | |||
| 63 | 77 | ||||
| 64 | - | done := make(chan struct{}) | 78 | + | // Publish runs in this goroutine: one that blocked on the slow |
| 65 | - | go func() { | 79 | + | // subscriber would deadlock the bubble and fail the test. |
| 66 | - | defer close(done) | |||
| 67 | for i := range 20 { | 80 | for i := range 20 { | ||
| 68 | b.Publish(ctx, ev(string(rune('a'+i)))) | 81 | b.Publish(ctx, ev(string(rune('a'+i)))) | ||
| 69 | // The fast subscriber keeps up. | 82 | // The fast subscriber keeps up. | ||
| 70 | - | recv(t, fast) | 83 | + | assert.Equal(t, ev(string(rune('a'+i))), recv(t, fast)) |
| 71 | } | 84 | } | ||
| 72 | - | }() | |||
| 73 | - | select { | |||
| 74 | - | case <-done: | |||
| 75 | - | case <-time.After(5 * time.Second): | |||
| 76 | - | t.Fatal("Publish blocked on a slow subscriber") | |||
| 77 | - | } | |||
| 78 | 85 | ||||
| 79 | - | // The slow one lost events: it sees a gap marker, then only recent events. | 86 | + | // The slow one lost events: it sees a gap marker, then only recent events. |
| 80 | - | var got []domain.Event | 87 | + | var got []domain.Event |
| 81 | - | for len(slow) > 0 { | 88 | + | for len(slow) > 0 { |
| 82 | - | got = append(got, <-slow) | 89 | + | got = append(got, <-slow) |
| 83 | - | } | |||
| 84 | - | require.NotEmpty(t, got) | |||
| 85 | - | var gapAt = -1 | |||
| 86 | - | for i, e := range got { | |||
| 87 | - | if _, ok := e.(domain.EventGap); ok { | |||
| 88 | - | gapAt = i | |||
| 89 | } | 90 | } | ||
| 90 | - | } | 91 | + | require.NotEmpty(t, got) |
| 91 | - | assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered") | 92 | + | var gapAt = -1 |
| 92 | - | assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered") | 93 | + | for i, e := range got { |
| 93 | - | assert.LessOrEqual(t, len(got), 4) | 94 | + | if _, ok := e.(domain.EventGap); ok { |
| 95 | + | gapAt = i | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered") | |||
| 99 | + | assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered") | |||
| 100 | + | assert.LessOrEqual(t, len(got), 4) | |||
| 101 | + | }) | |||
| 94 | } | 102 | } | ||
| 95 | 103 | ||||
| 96 | func TestUnsubscribeOnContext(t *testing.T) { | 104 | func TestUnsubscribeOnContext(t *testing.T) { | ||
| 97 | - | b := eventbus.New() | 105 | + | t.Parallel() |
| 98 | - | ctx, cancel := context.WithCancel(context.Background()) | 106 | + | synctest.Test(t, func(t *testing.T) { |
| 99 | - | ch := b.Subscribe(ctx) | 107 | + | b := eventbus.New() |
| 100 | - | require.Equal(t, 1, b.Subscribers()) | 108 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 101 | - | cancel() | 109 | + | ch := b.Subscribe(ctx) |
| 102 | - | select { | 110 | + | require.Equal(t, 1, b.Subscribers()) |
| 103 | - | case _, ok := <-ch: | 111 | + | synctest.Wait() |
| 104 | - | assert.False(t, ok) | 112 | + | require.Equal(t, 1, b.Subscribers(), "still subscribed while ctx lives") |
| 105 | - | case <-time.After(2 * time.Second): | 113 | + | cancel() |
| 106 | - | t.Fatal("channel not closed") | 114 | + | synctest.Wait() // the AfterFunc of the context has run |
| 107 | - | } | 115 | + | select { |
| 108 | - | assert.Equal(t, 0, b.Subscribers()) | 116 | + | case _, ok := <-ch: |
| 109 | - | b.Publish(context.Background(), ev("x")) // no panic | 117 | + | assert.False(t, ok) |
| 118 | + | default: | |||
| 119 | + | t.Fatal("channel not closed") | |||
| 120 | + | } | |||
| 121 | + | assert.Equal(t, 0, b.Subscribers()) | |||
| 122 | + | b.Publish(context.Background(), ev("x")) // no panic | |||
| 123 | + | }) | |||
| 110 | } | 124 | } | ||
| 111 | 125 | ||||
| 112 | func TestConcurrentPublishSubscribe(t *testing.T) { | 126 | func TestConcurrentPublishSubscribe(t *testing.T) { | ||
| 113 | - | b := eventbus.NewWithBuffer(8) | 127 | + | t.Parallel() |
| 114 | - | ctx, cancel := context.WithCancel(context.Background()) | 128 | + | synctest.Test(t, func(t *testing.T) { |
| 115 | - | var wg sync.WaitGroup | 129 | + | b := eventbus.NewWithBuffer(8) |
| 116 | - | for range 4 { | 130 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 117 | - | wg.Add(2) | 131 | + | var wg sync.WaitGroup |
| 118 | - | go func() { | 132 | + | for range 4 { |
| 119 | - | defer wg.Done() | 133 | + | wg.Add(2) |
| 120 | - | for range 200 { | 134 | + | go func() { |
| 121 | - | b.Publish(ctx, ev("x")) | 135 | + | defer wg.Done() |
| 122 | - | } | 136 | + | for range 200 { |
| 123 | - | }() | 137 | + | b.Publish(ctx, ev("x")) |
| 124 | - | go func() { | |||
| 125 | - | defer wg.Done() | |||
| 126 | - | sctx, scancel := context.WithCancel(ctx) | |||
| 127 | - | ch := b.Subscribe(sctx) | |||
| 128 | - | for range 5 { | |||
| 129 | - | select { | |||
| 130 | - | case <-ch: | |||
| 131 | - | case <-time.After(10 * time.Millisecond): | |||
| 132 | } | 138 | } | ||
| 133 | - | } | 139 | + | }() |
| 134 | - | scancel() | 140 | + | go func() { |
| 135 | - | }() | 141 | + | defer wg.Done() |
| 136 | - | } | 142 | + | sctx, scancel := context.WithCancel(ctx) |
| 137 | - | wg.Wait() | 143 | + | ch := b.Subscribe(sctx) |
| 138 | - | cancel() | 144 | + | for range 5 { |
| 145 | + | select { | |||
| 146 | + | case <-ch: | |||
| 147 | + | default: // nothing buffered yet: Publish never blocks, nor does this | |||
| 148 | + | } | |||
| 149 | + | runtime.Gosched() | |||
| 150 | + | } | |||
| 151 | + | scancel() | |||
| 152 | + | }() | |||
| 153 | + | } | |||
| 154 | + | wg.Wait() | |||
| 155 | + | cancel() | |||
| 156 | + | synctest.Wait() | |||
| 157 | + | assert.Zero(t, b.Subscribers(), "every subscription ended") | |||
| 158 | + | }) | |||
| 139 | } | 159 | } | ||
M internal/adapters/gitcli/backend.go +7 -0
| @@ -32,6 +32,8 @@ func HooksDir(dataDir string) string { return filepath.Join(dataDir, "run", "hoo | |||||
| 32 | // core.hooksPath=<data>/run/hooks | 32 | // core.hooksPath=<data>/run/hooks | ||
| 33 | // receive.fsckObjects=true | 33 | // receive.fsckObjects=true | ||
| 34 | // uploadpack.allowFilter=true | 34 | // uploadpack.allowFilter=true | ||
| 35 | + | // pack.threads=1 | |||
| 36 | + | // pack.windowMemory=256m | |||
| 35 | // http.receivepack=true (push only) | 37 | // http.receivepack=true (push only) | ||
| 36 | // receive.maxInputSize=<max> (push only, when configured) | 38 | // receive.maxInputSize=<max> (push only, when configured) | ||
| 37 | // | 39 | // | ||
| @@ -45,6 +47,11 @@ func (g *Git) HTTPBackend(ctx context.Context, push bool) (ports.HTTPBackend, er | |||||
| 45 | {"core.hooksPath", HooksDir(g.dataDir)}, | 47 | {"core.hooksPath", HooksDir(g.dataDir)}, | ||
| 46 | {"receive.fsckObjects", "true"}, | 48 | {"receive.fsckObjects", "true"}, | ||
| 47 | {"uploadpack.allowFilter", "true"}, | 49 | {"uploadpack.allowFilter", "true"}, | ||
| 50 | + | // pack-objects defaults to one thread per CPU and an unbounded delta | |||
| 51 | + | // window; with several backends at once that is the memory and CPU | |||
| 52 | + | // of the host. The backends are bounded by the handler instead. | |||
| 53 | + | {"pack.threads", "1"}, | |||
| 54 | + | {"pack.windowMemory", "256m"}, | |||
| 48 | } | 55 | } | ||
| 49 | if push { | 56 | if push { | ||
| 50 | conf = append(conf, [2]string{"http.receivepack", "true"}) | 57 | conf = append(conf, [2]string{"http.receivepack", "true"}) | ||
M internal/adapters/gitcli/backend_test.go +4 -0
| @@ -86,6 +86,8 @@ func TestHTTPBackendEnvironment(t *testing.T) { | |||||
| 86 | "core.hooksPath": filepath.Join(data, "run", "hooks"), | 86 | "core.hooksPath": filepath.Join(data, "run", "hooks"), | ||
| 87 | "receive.fsckObjects": "true", | 87 | "receive.fsckObjects": "true", | ||
| 88 | "uploadpack.allowFilter": "true", | 88 | "uploadpack.allowFilter": "true", | ||
| 89 | + | "pack.threads": "1", | |||
| 90 | + | "pack.windowMemory": "256m", | |||
| 89 | }, backendConf(e)) | 91 | }, backendConf(e)) | ||
| 90 | 92 | ||||
| 91 | push, err := g.HTTPBackend(ctx, true) | 93 | push, err := g.HTTPBackend(ctx, true) | ||
| @@ -96,6 +98,8 @@ func TestHTTPBackendEnvironment(t *testing.T) { | |||||
| 96 | "core.hooksPath": filepath.Join(data, "run", "hooks"), | 98 | "core.hooksPath": filepath.Join(data, "run", "hooks"), | ||
| 97 | "receive.fsckObjects": "true", | 99 | "receive.fsckObjects": "true", | ||
| 98 | "uploadpack.allowFilter": "true", | 100 | "uploadpack.allowFilter": "true", | ||
| 101 | + | "pack.threads": "1", | |||
| 102 | + | "pack.windowMemory": "256m", | |||
| 99 | "http.receivepack": "true", | 103 | "http.receivepack": "true", | ||
| 100 | }, backendConf(pe), "http.receivepack only for a push; no size limit unless configured") | 104 | }, backendConf(pe), "http.receivepack only for a push; no size limit unless configured") | ||
| 101 | 105 | ||||
M internal/adapters/gitcli/catfile.go +175 -67
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "io" | 11 | "io" | ||
| 12 | "os/exec" | 12 | "os/exec" | ||
| 13 | "path/filepath" | 13 | "path/filepath" | ||
| 14 | + | "slices" | |||
| 14 | "strconv" | 15 | "strconv" | ||
| 15 | "strings" | 16 | "strings" | ||
| 16 | "sync" | 17 | "sync" | ||
| @@ -36,27 +37,40 @@ func (e *procError) Unwrap() error { return e.err } | |||||
| 36 | const ( | 37 | const ( | ||
| 37 | defaultIdleTimeout = 60 * time.Second | 38 | defaultIdleTimeout = 60 * time.Second | ||
| 38 | defaultMaxProcesses = 16 | 39 | defaultMaxProcesses = 16 | ||
| 40 | + | // maxProcsPerRepo bounds the processes one repository may hold. A | |||
| 41 | + | // popular repository spawns more than one only under contention. | |||
| 42 | + | maxProcsPerRepo = 4 | |||
| 39 | ) | 43 | ) | ||
| 40 | 44 | ||||
| 41 | - | // catPool keeps one long-lived `git cat-file --batch-command` per repository. | 45 | + | // catPool keeps long-lived `git cat-file --batch-command` processes, a few per |
| 46 | + | // repository. | |||
| 42 | // | 47 | // | ||
| 43 | - | // - Access to one process is serialized: one request in flight at a time. | 48 | + | // - A process serves one request at a time. A request takes an idle process |
| 49 | + | // of the repository; if all are busy and the repository holds fewer than | |||
| 50 | + | // perRepo, a new one is started (so a repository nobody reads | |||
| 51 | + | // concurrently keeps a single process); otherwise the request waits, in | |||
| 52 | + | // order, for a process to be released. | |||
| 44 | // - A process nobody used for the idle timeout is killed. | 53 | // - A process nobody used for the idle timeout is killed. | ||
| 45 | - | // - At most max processes are kept. Starting another one first evicts the | 54 | + | // - At most max processes are kept, all repositories together. Starting |
| 46 | - | // least recently used idle process; a process that is in use is never | 55 | + | // another one first evicts the least recently used idle process; a |
| 47 | - | // evicted, so the bound is soft when more than max repositories are read | 56 | + | // process that is in use is never evicted. When none can be evicted, a |
| 48 | - | // at the same moment. | 57 | + | // repository that has a process waits for it, and one that has none starts |
| 58 | + | // one anyway, so the bound is soft only when more than max repositories | |||
| 59 | + | // are read at the same moment. | |||
| 49 | // - A process that fails, or whose request was cancelled (its output is then | 60 | // - A process that fails, or whose request was cancelled (its output is then | ||
| 50 | // in an unknown state), is killed and dropped; the next request starts a | 61 | // in an unknown state), is killed and dropped; the next request starts a | ||
| 51 | // fresh one, and a request that hit a dead process is retried once. | 62 | // fresh one, and a request that hit a dead process is retried once. | ||
| 52 | type catPool struct { | 63 | type catPool struct { | ||
| 53 | - | bin string | 64 | + | bin string |
| 54 | - | env []string | 65 | + | env []string |
| 55 | - | idle time.Duration | 66 | + | idle time.Duration |
| 56 | - | max int | 67 | + | max int // processes, all repositories together |
| 68 | + | perRepo int // processes of one repository | |||
| 57 | 69 | ||||
| 58 | mu sync.Mutex | 70 | mu sync.Mutex | ||
| 59 | - | procs map[string]*catProc | 71 | + | procs map[string][]*catProc // by repository directory |
| 72 | + | total int // live processes in procs | |||
| 73 | + | waiters map[string][]chan *catProc | |||
| 60 | streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob | 74 | streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob | ||
| 61 | closed bool | 75 | closed bool | ||
| 62 | wg sync.WaitGroup // reapers of started processes | 76 | wg sync.WaitGroup // reapers of started processes | ||
| @@ -71,11 +85,10 @@ type catProc struct { | |||||
| 71 | in io.WriteCloser | 85 | in io.WriteCloser | ||
| 72 | out *bufio.Reader | 86 | out *bufio.Reader | ||
| 73 | stderr *tailBuffer | 87 | stderr *tailBuffer | ||
| 74 | - | sem chan struct{} // capacity 1: the request in flight | |||
| 75 | 88 | ||||
| 76 | - | broken bool // set by the holder of sem on a protocol error | 89 | + | broken bool // set by the holder on a protocol error |
| 77 | 90 | ||||
| 78 | - | users int // requests waiting for or holding sem | 91 | + | busy bool // a request holds the process |
| 79 | lastUsed time.Time | 92 | lastUsed time.Time | ||
| 80 | timer *time.Timer | 93 | timer *time.Timer | ||
| 81 | gen uint64 | 94 | gen uint64 | ||
| @@ -90,13 +103,14 @@ func newCatPool(bin string, env []string, idle time.Duration, maxProcs int) *cat | |||||
| 90 | maxProcs = defaultMaxProcesses | 103 | maxProcs = defaultMaxProcesses | ||
| 91 | } | 104 | } | ||
| 92 | return &catPool{ | 105 | return &catPool{ | ||
| 93 | - | bin: bin, env: env, idle: idle, max: maxProcs, | 106 | + | bin: bin, env: env, idle: idle, max: maxProcs, perRepo: min(maxProcsPerRepo, maxProcs), |
| 94 | - | procs: make(map[string]*catProc), | 107 | + | procs: make(map[string][]*catProc), |
| 108 | + | waiters: make(map[string][]chan *catProc), | |||
| 95 | streams: make(map[*blobStream]struct{}), | 109 | streams: make(map[*blobStream]struct{}), | ||
| 96 | } | 110 | } | ||
| 97 | } | 111 | } | ||
| 98 | 112 | ||||
| 99 | - | // do runs fn with exclusive use of the cat-file process of the repository in | 113 | + | // do runs fn with exclusive use of a cat-file process of the repository in |
| 100 | // dir, starting it if needed. fn must not keep the connection after returning. | 114 | // dir, starting it if needed. fn must not keep the connection after returning. | ||
| 101 | func (p *catPool) do(ctx context.Context, dir string, fn func(*catConn) error) error { | 115 | func (p *catPool) do(ctx context.Context, dir string, fn func(*catConn) error) error { | ||
| 102 | for attempt := 0; ; attempt++ { | 116 | for attempt := 0; ; attempt++ { | ||
| @@ -132,7 +146,6 @@ func (p *catPool) run(ctx context.Context, cp *catProc, fn func(*catConn) error) | |||||
| 132 | err = ctx.Err() | 146 | err = ctx.Err() | ||
| 133 | } | 147 | } | ||
| 134 | p.release(cp, cp.broken || cancelled) | 148 | p.release(cp, cp.broken || cancelled) | ||
| 135 | - | <-cp.sem | |||
| 136 | return err | 149 | return err | ||
| 137 | } | 150 | } | ||
| 138 | 151 | ||||
| @@ -142,58 +155,149 @@ func (p *catPool) isClosed() bool { | |||||
| 142 | return p.closed | 155 | return p.closed | ||
| 143 | } | 156 | } | ||
| 144 | 157 | ||||
| 158 | + | // acquire returns a process of dir that no other request uses. | |||
| 145 | func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) { | 159 | func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) { | ||
| 146 | for { | 160 | for { | ||
| 161 | + | if err := ctx.Err(); err != nil { | |||
| 162 | + | return nil, err | |||
| 163 | + | } | |||
| 147 | p.mu.Lock() | 164 | p.mu.Lock() | ||
| 148 | if p.closed { | 165 | if p.closed { | ||
| 149 | p.mu.Unlock() | 166 | p.mu.Unlock() | ||
| 150 | return nil, ErrClosed | 167 | return nil, ErrClosed | ||
| 151 | } | 168 | } | ||
| 152 | - | cp := p.procs[dir] | 169 | + | cp, wait, err := p.takeLocked(ctx, dir) |
| 153 | - | if cp == nil { | 170 | + | if err != nil { |
| 154 | - | var err error | 171 | + | p.mu.Unlock() |
| 155 | - | if cp, err = p.startLocked(ctx, dir); err != nil { | 172 | + | return nil, err |
| 156 | - | p.mu.Unlock() | |||
| 157 | - | return nil, err | |||
| 158 | - | } | |||
| 159 | } | 173 | } | ||
| 160 | - | cp.users++ | 174 | + | if cp != nil { |
| 161 | - | cp.gen++ | 175 | + | p.mu.Unlock() |
| 162 | - | if cp.timer != nil { | 176 | + | return cp, nil |
| 163 | - | cp.timer.Stop() | |||
| 164 | - | cp.timer = nil | |||
| 165 | } | 177 | } | ||
| 178 | + | p.waiters[dir] = append(p.waiters[dir], wait) | |||
| 166 | p.mu.Unlock() | 179 | p.mu.Unlock() | ||
| 167 | 180 | ||||
| 168 | select { | 181 | select { | ||
| 169 | - | case cp.sem <- struct{}{}: | 182 | + | case cp := <-wait: |
| 183 | + | if cp == nil { | |||
| 184 | + | continue // the processes changed: look again | |||
| 185 | + | } | |||
| 186 | + | p.mu.Lock() | |||
| 187 | + | dead := cp.dead | |||
| 188 | + | p.mu.Unlock() | |||
| 189 | + | if !dead { | |||
| 190 | + | return cp, nil | |||
| 191 | + | } | |||
| 192 | + | p.release(cp, false) // dropped between the hand-over and now | |||
| 170 | case <-ctx.Done(): | 193 | case <-ctx.Done(): | ||
| 171 | - | p.release(cp, false) | 194 | + | p.mu.Lock() |
| 195 | + | if !p.unqueueLocked(dir, wait) { | |||
| 196 | + | // A process was handed over meanwhile: pass it on. | |||
| 197 | + | p.mu.Unlock() | |||
| 198 | + | if cp := <-wait; cp != nil { | |||
| 199 | + | p.release(cp, false) | |||
| 200 | + | } | |||
| 201 | + | } else { | |||
| 202 | + | p.mu.Unlock() | |||
| 203 | + | } | |||
| 172 | return nil, ctx.Err() | 204 | return nil, ctx.Err() | ||
| 173 | } | 205 | } | ||
| 174 | - | p.mu.Lock() | 206 | + | } |
| 175 | - | dead := cp.dead | 207 | + | } |
| 176 | - | p.mu.Unlock() | 208 | + | |
| 177 | - | if !dead { | 209 | + | // takeLocked marks an idle process of dir busy and returns it, starting one |
| 178 | - | return cp, nil | 210 | + | // when none is idle and the repository may have more. Otherwise it returns the |
| 211 | + | // channel to wait on. | |||
| 212 | + | func (p *catPool) takeLocked(ctx context.Context, dir string) (*catProc, chan *catProc, error) { | |||
| 213 | + | var best *catProc // the most recently used idle one, so that the others idle out | |||
| 214 | + | for _, c := range p.procs[dir] { | |||
| 215 | + | if !c.busy && (best == nil || c.lastUsed.After(best.lastUsed)) { | |||
| 216 | + | best = c | |||
| 179 | } | 217 | } | ||
| 180 | - | // Dropped while we waited for our turn. | |||
| 181 | - | <-cp.sem | |||
| 182 | - | p.release(cp, false) | |||
| 183 | } | 218 | } | ||
| 219 | + | if best == nil { | |||
| 220 | + | own := len(p.procs[dir]) | |||
| 221 | + | if own < p.perRepo && (p.makeRoomLocked() || own == 0) { | |||
| 222 | + | var err error | |||
| 223 | + | if best, err = p.startLocked(ctx, dir); err != nil { | |||
| 224 | + | return nil, nil, err | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | if best == nil { | |||
| 229 | + | return nil, make(chan *catProc, 1), nil | |||
| 230 | + | } | |||
| 231 | + | best.busy = true | |||
| 232 | + | best.gen++ | |||
| 233 | + | if best.timer != nil { | |||
| 234 | + | best.timer.Stop() | |||
| 235 | + | best.timer = nil | |||
| 236 | + | } | |||
| 237 | + | return best, nil, nil | |||
| 184 | } | 238 | } | ||
| 185 | 239 | ||||
| 186 | - | // release ends one use of cp. A broken process is dropped; an unused one | 240 | + | // makeRoomLocked evicts least recently used idle processes until another one |
| 187 | - | // starts its idle countdown. | 241 | + | // fits, and reports whether it does. |
| 242 | + | func (p *catPool) makeRoomLocked() bool { | |||
| 243 | + | for p.total >= p.max { | |||
| 244 | + | var victim *catProc | |||
| 245 | + | for _, procs := range p.procs { | |||
| 246 | + | for _, c := range procs { | |||
| 247 | + | if !c.busy && (victim == nil || c.lastUsed.Before(victim.lastUsed)) { | |||
| 248 | + | victim = c | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | if victim == nil { | |||
| 253 | + | return false | |||
| 254 | + | } | |||
| 255 | + | victim.log.Debug().Str("dir", victim.dir).Msg("cat-file pool full, evicting the least recently used") | |||
| 256 | + | p.dropLocked(victim) | |||
| 257 | + | } | |||
| 258 | + | return true | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // unqueueLocked removes a waiter and reports whether it was still queued. | |||
| 262 | + | func (p *catPool) unqueueLocked(dir string, wait chan *catProc) bool { | |||
| 263 | + | q := p.waiters[dir] | |||
| 264 | + | for i, w := range q { | |||
| 265 | + | if w == wait { | |||
| 266 | + | q = append(q[:i:i], q[i+1:]...) | |||
| 267 | + | if len(q) == 0 { | |||
| 268 | + | delete(p.waiters, dir) | |||
| 269 | + | } else { | |||
| 270 | + | p.waiters[dir] = q | |||
| 271 | + | } | |||
| 272 | + | return true | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | return false | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // release ends one use of cp. A broken process is dropped. A working one goes | |||
| 279 | + | // to the request that waited longest, or else starts its idle countdown. | |||
| 188 | func (p *catPool) release(cp *catProc, broken bool) { | 280 | func (p *catPool) release(cp *catProc, broken bool) { | ||
| 189 | p.mu.Lock() | 281 | p.mu.Lock() | ||
| 190 | defer p.mu.Unlock() | 282 | defer p.mu.Unlock() | ||
| 191 | - | cp.users-- | 283 | + | if broken || cp.dead { |
| 192 | - | if broken { | 284 | + | cp.busy = false |
| 193 | p.dropLocked(cp) | 285 | p.dropLocked(cp) | ||
| 194 | return | 286 | return | ||
| 195 | } | 287 | } | ||
| 196 | - | if cp.users == 0 && !cp.dead && !p.closed { | 288 | + | if q := p.waiters[cp.dir]; len(q) > 0 && !p.closed { |
| 289 | + | w := q[0] | |||
| 290 | + | if len(q) == 1 { | |||
| 291 | + | delete(p.waiters, cp.dir) | |||
| 292 | + | } else { | |||
| 293 | + | p.waiters[cp.dir] = q[1:] | |||
| 294 | + | } | |||
| 295 | + | cp.gen++ | |||
| 296 | + | w <- cp // stays busy: handed over | |||
| 297 | + | return | |||
| 298 | + | } | |||
| 299 | + | cp.busy = false | |||
| 300 | + | if !p.closed { | |||
| 197 | cp.lastUsed = time.Now() | 301 | cp.lastUsed = time.Now() | ||
| 198 | gen := cp.gen | 302 | gen := cp.gen | ||
| 199 | cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) }) | 303 | cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) }) | ||
| @@ -204,7 +308,7 @@ func (p *catPool) release(cp *catProc, broken bool) { | |||||
| 204 | func (p *catPool) expire(cp *catProc, gen uint64) { | 308 | func (p *catPool) expire(cp *catProc, gen uint64) { | ||
| 205 | p.mu.Lock() | 309 | p.mu.Lock() | ||
| 206 | defer p.mu.Unlock() | 310 | defer p.mu.Unlock() | ||
| 207 | - | if cp.gen != gen || cp.users != 0 || cp.dead { | 311 | + | if cp.gen != gen || cp.busy || cp.dead { |
| 208 | return | 312 | return | ||
| 209 | } | 313 | } | ||
| 210 | cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping") | 314 | cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping") | ||
| @@ -212,14 +316,24 @@ func (p *catPool) expire(cp *catProc, gen uint64) { | |||||
| 212 | } | 316 | } | ||
| 213 | 317 | ||||
| 214 | // dropLocked removes cp from the pool and kills it. The reaper goroutine | 318 | // dropLocked removes cp from the pool and kills it. The reaper goroutine | ||
| 215 | - | // collects the process. | 319 | + | // collects the process. Requests waiting for the repository look again. |
| 216 | func (p *catPool) dropLocked(cp *catProc) { | 320 | func (p *catPool) dropLocked(cp *catProc) { | ||
| 217 | if cp.dead { | 321 | if cp.dead { | ||
| 218 | return | 322 | return | ||
| 219 | } | 323 | } | ||
| 220 | cp.dead = true | 324 | cp.dead = true | ||
| 221 | - | if p.procs[cp.dir] == cp { | 325 | + | procs := p.procs[cp.dir] |
| 326 | + | for i, c := range procs { | |||
| 327 | + | if c == cp { | |||
| 328 | + | procs = append(procs[:i:i], procs[i+1:]...) | |||
| 329 | + | p.total-- | |||
| 330 | + | break | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | if len(procs) == 0 { | |||
| 222 | delete(p.procs, cp.dir) | 334 | delete(p.procs, cp.dir) | ||
| 335 | + | } else { | |||
| 336 | + | p.procs[cp.dir] = procs | |||
| 223 | } | 337 | } | ||
| 224 | if cp.timer != nil { | 338 | if cp.timer != nil { | ||
| 225 | cp.timer.Stop() | 339 | cp.timer.Stop() | ||
| @@ -227,22 +341,13 @@ func (p *catPool) dropLocked(cp *catProc) { | |||||
| 227 | } | 341 | } | ||
| 228 | _ = cp.in.Close() | 342 | _ = cp.in.Close() | ||
| 229 | _ = cp.cmd.Process.Kill() | 343 | _ = cp.cmd.Process.Kill() | ||
| 344 | + | for _, w := range p.waiters[cp.dir] { | |||
| 345 | + | w <- nil | |||
| 346 | + | } | |||
| 347 | + | delete(p.waiters, cp.dir) | |||
| 230 | } | 348 | } | ||
| 231 | 349 | ||||
| 232 | func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) { | 350 | func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) { | ||
| 233 | - | for len(p.procs) >= p.max { | |||
| 234 | - | var victim *catProc | |||
| 235 | - | for _, c := range p.procs { | |||
| 236 | - | if c.users == 0 && (victim == nil || c.lastUsed.Before(victim.lastUsed)) { | |||
| 237 | - | victim = c | |||
| 238 | - | } | |||
| 239 | - | } | |||
| 240 | - | if victim == nil { | |||
| 241 | - | break // all in use: exceed the bound rather than block | |||
| 242 | - | } | |||
| 243 | - | victim.log.Debug().Str("dir", victim.dir).Msg("cat-file pool full, evicting the least recently used") | |||
| 244 | - | p.dropLocked(victim) | |||
| 245 | - | } | |||
| 246 | cmd := exec.Command(p.bin, "--git-dir="+dir, "--no-replace-objects", "--no-optional-locks", "cat-file", "--batch-command="+catFormat, "-Z") | 351 | cmd := exec.Command(p.bin, "--git-dir="+dir, "--no-replace-objects", "--no-optional-locks", "cat-file", "--batch-command="+catFormat, "-Z") | ||
| 247 | cmd.Env = p.env | 352 | cmd.Env = p.env | ||
| 248 | cmd.Dir = filepath.Dir(dir) | 353 | cmd.Dir = filepath.Dir(dir) | ||
| @@ -261,9 +366,10 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) | |||||
| 261 | } | 366 | } | ||
| 262 | cp := &catProc{ | 367 | cp := &catProc{ | ||
| 263 | dir: dir, log: zerowrap.FromCtx(ctx), cmd: cmd, in: in, out: bufio.NewReaderSize(stdout, 64*1024), | 368 | dir: dir, log: zerowrap.FromCtx(ctx), cmd: cmd, in: in, out: bufio.NewReaderSize(stdout, 64*1024), | ||
| 264 | - | stderr: stderr, sem: make(chan struct{}, 1), lastUsed: time.Now(), | 369 | + | stderr: stderr, lastUsed: time.Now(), |
| 265 | } | 370 | } | ||
| 266 | - | p.procs[dir] = cp | 371 | + | p.procs[dir] = append(p.procs[dir], cp) |
| 372 | + | p.total++ | |||
| 267 | p.wg.Add(1) | 373 | p.wg.Add(1) | ||
| 268 | go func() { | 374 | go func() { | ||
| 269 | defer p.wg.Done() | 375 | defer p.wg.Done() | ||
| @@ -273,11 +379,11 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) | |||||
| 273 | return cp, nil | 379 | return cp, nil | ||
| 274 | } | 380 | } | ||
| 275 | 381 | ||||
| 276 | - | // forget kills the process of the repository in dir, if any. | 382 | + | // forget kills the processes of the repository in dir, if any. |
| 277 | func (p *catPool) forget(dir string) { | 383 | func (p *catPool) forget(dir string) { | ||
| 278 | p.mu.Lock() | 384 | p.mu.Lock() | ||
| 279 | defer p.mu.Unlock() | 385 | defer p.mu.Unlock() | ||
| 280 | - | if cp := p.procs[dir]; cp != nil { | 386 | + | for _, cp := range slices.Clone(p.procs[dir]) { |
| 281 | p.dropLocked(cp) | 387 | p.dropLocked(cp) | ||
| 282 | } | 388 | } | ||
| 283 | } | 389 | } | ||
| @@ -287,8 +393,10 @@ func (p *catPool) forget(dir string) { | |||||
| 287 | func (p *catPool) close(ctx context.Context) error { | 393 | func (p *catPool) close(ctx context.Context) error { | ||
| 288 | p.mu.Lock() | 394 | p.mu.Lock() | ||
| 289 | p.closed = true | 395 | p.closed = true | ||
| 290 | - | for _, cp := range p.procs { | 396 | + | for _, procs := range p.procs { |
| 291 | - | p.dropLocked(cp) | 397 | + | for _, cp := range slices.Clone(procs) { |
| 398 | + | p.dropLocked(cp) | |||
| 399 | + | } | |||
| 292 | } | 400 | } | ||
| 293 | streams := make([]*blobStream, 0, len(p.streams)) | 401 | streams := make([]*blobStream, 0, len(p.streams)) | ||
| 294 | for s := range p.streams { | 402 | for s := range p.streams { | ||
M internal/adapters/gitcli/export_test.go +40 -7
| @@ -2,7 +2,12 @@ | |||||
| 2 | 2 | ||||
| 3 | package gitcli | 3 | package gitcli | ||
| 4 | 4 | ||||
| 5 | - | import "sort" | 5 | + | import ( |
| 6 | + | "context" | |||
| 7 | + | "sort" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite/internal/domain" | |||
| 10 | + | ) | |||
| 6 | 11 | ||||
| 7 | // CatFilePIDs returns the process ids of the live long-lived cat-file | 12 | // CatFilePIDs returns the process ids of the live long-lived cat-file | ||
| 8 | // processes, for the tests of the pool. | 13 | // processes, for the tests of the pool. | ||
| @@ -10,8 +15,10 @@ func (g *Git) CatFilePIDs() []int { | |||||
| 10 | g.cat.mu.Lock() | 15 | g.cat.mu.Lock() | ||
| 11 | defer g.cat.mu.Unlock() | 16 | defer g.cat.mu.Unlock() | ||
| 12 | var pids []int | 17 | var pids []int | ||
| 13 | - | for _, cp := range g.cat.procs { | 18 | + | for _, procs := range g.cat.procs { |
| 14 | - | pids = append(pids, cp.cmd.Process.Pid) | 19 | + | for _, cp := range procs { |
| 20 | + | pids = append(pids, cp.cmd.Process.Pid) | |||
| 21 | + | } | |||
| 15 | } | 22 | } | ||
| 16 | sort.Ints(pids) | 23 | sort.Ints(pids) | ||
| 17 | return pids | 24 | return pids | ||
| @@ -44,8 +51,10 @@ func (g *Git) FireIdleTimers() { | |||||
| 44 | gen uint64 | 51 | gen uint64 | ||
| 45 | } | 52 | } | ||
| 46 | var all []armed | 53 | var all []armed | ||
| 47 | - | for _, cp := range g.cat.procs { | 54 | + | for _, procs := range g.cat.procs { |
| 48 | - | all = append(all, armed{cp, cp.gen}) | 55 | + | for _, cp := range procs { |
| 56 | + | all = append(all, armed{cp, cp.gen}) | |||
| 57 | + | } | |||
| 49 | } | 58 | } | ||
| 50 | g.cat.mu.Unlock() | 59 | g.cat.mu.Unlock() | ||
| 51 | for _, a := range all { | 60 | for _, a := range all { | ||
| @@ -62,8 +71,10 @@ func (g *Git) FireStaleIdleTimers() { | |||||
| 62 | gen uint64 | 71 | gen uint64 | ||
| 63 | } | 72 | } | ||
| 64 | var all []armed | 73 | var all []armed | ||
| 65 | - | for _, cp := range g.cat.procs { | 74 | + | for _, procs := range g.cat.procs { |
| 66 | - | all = append(all, armed{cp, cp.gen - 1}) | 75 | + | for _, cp := range procs { |
| 76 | + | all = append(all, armed{cp, cp.gen - 1}) | |||
| 77 | + | } | |||
| 67 | } | 78 | } | ||
| 68 | g.cat.mu.Unlock() | 79 | g.cat.mu.Unlock() | ||
| 69 | for _, a := range all { | 80 | for _, a := range all { | ||
| @@ -74,3 +85,25 @@ func (g *Git) FireStaleIdleTimers() { | |||||
| 74 | // ExecSlots returns the size of the one-shot process semaphore and how many | 85 | // ExecSlots returns the size of the one-shot process semaphore and how many | ||
| 75 | // slots are taken. | 86 | // slots are taken. | ||
| 76 | func (g *Git) ExecSlots() (size, taken int) { return cap(g.exec), len(g.exec) } | 87 | func (g *Git) ExecSlots() (size, taken int) { return cap(g.exec), len(g.exec) } | ||
| 88 | + | ||||
| 89 | + | // HoldCatFile runs fn while holding a cat-file process of the repository, as | |||
| 90 | + | // a request in flight would. | |||
| 91 | + | func (g *Git) HoldCatFile(ctx context.Context, repo domain.RepoName, fn func()) error { | |||
| 92 | + | dir, err := g.repoDir(repo) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | return g.cat.do(ctx, dir, func(*catConn) error { fn(); return nil }) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // CatFileWaiting returns the number of requests that wait for a cat-file | |||
| 100 | + | // process. | |||
| 101 | + | func (g *Git) CatFileWaiting() int { | |||
| 102 | + | g.cat.mu.Lock() | |||
| 103 | + | defer g.cat.mu.Unlock() | |||
| 104 | + | n := 0 | |||
| 105 | + | for _, q := range g.cat.waiters { | |||
| 106 | + | n += len(q) | |||
| 107 | + | } | |||
| 108 | + | return n | |||
| 109 | + | } | |||
M internal/adapters/gitcli/pool_test.go +166 -2
| @@ -177,7 +177,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) { | |||||
| 177 | assert.NotEqual(t, old, now) | 177 | assert.NotEqual(t, old, now) | ||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | - | func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | 180 | + | func TestConcurrentReadsAreCorrectAndBounded(t *testing.T) { |
| 181 | ctx := context.Background() | 181 | ctx := context.Background() | ||
| 182 | g, data := newReader(t, nil) | 182 | g, data := newReader(t, nil) | ||
| 183 | h := readertest.NewHistory(t, data) | 183 | h := readertest.NewHistory(t, data) | ||
| @@ -223,7 +223,8 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||||
| 223 | for err := range errs { | 223 | for err := range errs { | ||
| 224 | require.NoError(t, err) | 224 | require.NoError(t, err) | ||
| 225 | } | 225 | } | ||
| 226 | - | assert.Len(t, g.CatFilePIDs(), 1, "one process for one repository") | 226 | + | assert.NotEmpty(t, g.CatFilePIDs()) |
| 227 | + | assert.LessOrEqual(t, len(g.CatFilePIDs()), 4, "a repository holds a bounded number of processes") | |||
| 227 | } | 228 | } | ||
| 228 | 229 | ||||
| 229 | func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | 230 | func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | ||
| @@ -406,3 +407,166 @@ func TestBlobStreamsAreBounded(t *testing.T) { | |||||
| 406 | require.NoError(t, c.Close()) | 407 | require.NoError(t, c.Close()) | ||
| 407 | assert.Zero(t, g.BlobStreams()) | 408 | assert.Zero(t, g.BlobStreams()) | ||
| 408 | } | 409 | } | ||
| 410 | + | ||||
| 411 | + | // hold runs HoldCatFile in the background and returns once the process is | |||
| 412 | + | // held. The returned func releases it and waits for the request to end. | |||
| 413 | + | func hold(t *testing.T, g *gitcli.Git, repo domain.RepoName) (release func()) { | |||
| 414 | + | t.Helper() | |||
| 415 | + | held, free, done := make(chan struct{}), make(chan struct{}), make(chan error, 1) | |||
| 416 | + | go func() { | |||
| 417 | + | done <- g.HoldCatFile(context.Background(), repo, func() { close(held); <-free }) | |||
| 418 | + | }() | |||
| 419 | + | select { | |||
| 420 | + | case <-held: | |||
| 421 | + | case err := <-done: | |||
| 422 | + | require.NoError(t, err) | |||
| 423 | + | t.Fatal("the request ended before it held a process") | |||
| 424 | + | } | |||
| 425 | + | var once sync.Once | |||
| 426 | + | release = func() { | |||
| 427 | + | once.Do(func() { close(free); require.NoError(t, <-done) }) | |||
| 428 | + | } | |||
| 429 | + | t.Cleanup(release) | |||
| 430 | + | return release | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | // A request in flight does not make the others of the repository wait: they | |||
| 434 | + | // get a process of their own, started only because of the contention. | |||
| 435 | + | func TestConcurrentReadsOnOneRepoDoNotSerialize(t *testing.T) { | |||
| 436 | + | ctx := context.Background() | |||
| 437 | + | g, data := newReader(t, nil) | |||
| 438 | + | h := readertest.NewHistory(t, data) | |||
| 439 | + | ||||
| 440 | + | _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) | |||
| 441 | + | require.NoError(t, err) | |||
| 442 | + | require.Len(t, g.CatFilePIDs(), 1, "no contention, one process") | |||
| 443 | + | ||||
| 444 | + | release := hold(t, g, h.Name) | |||
| 445 | + | // The slow request holds the only process; this one must not wait for it | |||
| 446 | + | // (it would block forever: the holder is released after). | |||
| 447 | + | res, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) | |||
| 448 | + | require.NoError(t, err) | |||
| 449 | + | assert.Equal(t, h.Merge, res.CommitID.String()) | |||
| 450 | + | pids := g.CatFilePIDs() | |||
| 451 | + | assert.Len(t, pids, 2, "the contention started a second process") | |||
| 452 | + | assert.Zero(t, g.CatFileWaiting()) | |||
| 453 | + | ||||
| 454 | + | release() | |||
| 455 | + | // Both stay for reuse until they idle out. | |||
| 456 | + | assert.Equal(t, pids, g.CatFilePIDs()) | |||
| 457 | + | g.FireIdleTimers() | |||
| 458 | + | assert.Empty(t, g.CatFilePIDs()) | |||
| 459 | + | requireGone(t, pids...) | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func TestCatFileProcessBounds(t *testing.T) { | |||
| 463 | + | tests := []struct { | |||
| 464 | + | name string | |||
| 465 | + | max int // Config.MaxCatFiles | |||
| 466 | + | // holds are the repositories, by index, whose process a request holds. | |||
| 467 | + | holds []int | |||
| 468 | + | // extra is the repository of one more request, which must wait. | |||
| 469 | + | extra int | |||
| 470 | + | wantProcs int | |||
| 471 | + | wantWaiting int | |||
| 472 | + | }{ | |||
| 473 | + | {name: "per repository cap", max: 16, holds: []int{0, 0, 0, 0}, extra: 0, wantProcs: 4, wantWaiting: 1}, | |||
| 474 | + | {name: "global cap, repository with a process waits", max: 3, holds: []int{0, 0, 1}, extra: 1, wantProcs: 3, wantWaiting: 1}, | |||
| 475 | + | {name: "global cap lower than the per repository cap", max: 2, holds: []int{0, 0}, extra: 0, wantProcs: 2, wantWaiting: 1}, | |||
| 476 | + | } | |||
| 477 | + | for _, tt := range tests { | |||
| 478 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 479 | + | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = tt.max }) | |||
| 480 | + | var names []domain.RepoName | |||
| 481 | + | for i := range 2 { | |||
| 482 | + | f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i)) | |||
| 483 | + | f.Write("x", "x").Commit("x") | |||
| 484 | + | names = append(names, f.Name) | |||
| 485 | + | } | |||
| 486 | + | var releases []func() | |||
| 487 | + | for _, i := range tt.holds { | |||
| 488 | + | releases = append(releases, hold(t, g, names[i])) | |||
| 489 | + | } | |||
| 490 | + | assert.Len(t, g.CatFilePIDs(), tt.wantProcs) | |||
| 491 | + | ||||
| 492 | + | done := make(chan error, 1) | |||
| 493 | + | go func() { | |||
| 494 | + | _, err := g.ResolveRevision(context.Background(), names[tt.extra], domain.Revision{}) | |||
| 495 | + | done <- err | |||
| 496 | + | }() | |||
| 497 | + | require.Eventually(t, func() bool { return g.CatFileWaiting() == tt.wantWaiting }, 5*time.Second, time.Millisecond) | |||
| 498 | + | assert.Len(t, g.CatFilePIDs(), tt.wantProcs, "a waiting request starts nothing") | |||
| 499 | + | select { | |||
| 500 | + | case err := <-done: | |||
| 501 | + | t.Fatalf("the request did not wait: %v", err) | |||
| 502 | + | default: | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | // A process released to a waiter serves it, without a new process. | |||
| 506 | + | for k, i := range tt.holds { | |||
| 507 | + | if i == tt.extra { | |||
| 508 | + | releases[k]() | |||
| 509 | + | break | |||
| 510 | + | } | |||
| 511 | + | } | |||
| 512 | + | require.NoError(t, <-done) | |||
| 513 | + | assert.LessOrEqual(t, len(g.CatFilePIDs()), tt.wantProcs) | |||
| 514 | + | assert.Zero(t, g.CatFileWaiting()) | |||
| 515 | + | }) | |||
| 516 | + | } | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | // A waiting request gives up with its context and leaves nothing queued. | |||
| 520 | + | func TestWaitingForAProcessEndsWithTheContext(t *testing.T) { | |||
| 521 | + | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 1 }) | |||
| 522 | + | h := readertest.NewHistory(t, data) | |||
| 523 | + | release := hold(t, g, h.Name) | |||
| 524 | + | ||||
| 525 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 526 | + | done := make(chan error, 1) | |||
| 527 | + | go func() { _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}); done <- err }() | |||
| 528 | + | require.Eventually(t, func() bool { return g.CatFileWaiting() == 1 }, 5*time.Second, time.Millisecond) | |||
| 529 | + | cancel() | |||
| 530 | + | assert.ErrorIs(t, <-done, context.Canceled) | |||
| 531 | + | assert.Zero(t, g.CatFileWaiting()) | |||
| 532 | + | assert.Len(t, g.CatFilePIDs(), 1) | |||
| 533 | + | ||||
| 534 | + | release() | |||
| 535 | + | _, err := g.ResolveRevision(context.Background(), h.Name, domain.Revision{}) | |||
| 536 | + | require.NoError(t, err, "the process survived the abandoned wait") | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | // The bound counts processes, not repositories: idle processes of several | |||
| 540 | + | // repositories are evicted to make room. | |||
| 541 | + | func TestPoolBoundCountsProcessesAcrossRepositories(t *testing.T) { | |||
| 542 | + | ctx := context.Background() | |||
| 543 | + | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 3 }) | |||
| 544 | + | var names []domain.RepoName | |||
| 545 | + | for i := range 4 { | |||
| 546 | + | f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i)) | |||
| 547 | + | f.Write("x", "x").Commit("x") | |||
| 548 | + | names = append(names, f.Name) | |||
| 549 | + | } | |||
| 550 | + | // Repository 0 grows to three processes. | |||
| 551 | + | releases := []func(){hold(t, g, names[0]), hold(t, g, names[0]), hold(t, g, names[0])} | |||
| 552 | + | require.Len(t, g.CatFilePIDs(), 3) | |||
| 553 | + | for _, r := range releases { | |||
| 554 | + | r() | |||
| 555 | + | } | |||
| 556 | + | before := g.CatFilePIDs() | |||
| 557 | + | ||||
| 558 | + | for _, n := range names[1:] { | |||
| 559 | + | _, err := g.ResolveRevision(ctx, n, domain.Revision{}) | |||
| 560 | + | require.NoError(t, err) | |||
| 561 | + | assert.LessOrEqual(t, len(g.CatFilePIDs()), 3) | |||
| 562 | + | } | |||
| 563 | + | live := map[int]bool{} | |||
| 564 | + | for _, p := range g.CatFilePIDs() { | |||
| 565 | + | live[p] = true | |||
| 566 | + | } | |||
| 567 | + | for _, p := range before { | |||
| 568 | + | if !live[p] { | |||
| 569 | + | requireGone(t, p) | |||
| 570 | + | } | |||
| 571 | + | } | |||
| 572 | + | } | |||
M internal/adapters/githttp/handler.go +150 -42
| @@ -7,16 +7,21 @@ | |||||
| 7 | package githttp | 7 | package githttp | ||
| 8 | 8 | ||||
| 9 | import ( | 9 | import ( | ||
| 10 | + | "cmp" | |||
| 10 | "context" | 11 | "context" | ||
| 11 | "errors" | 12 | "errors" | ||
| 13 | + | "io" | |||
| 12 | "log" | 14 | "log" | ||
| 13 | "math" | 15 | "math" | ||
| 14 | "net/http" | 16 | "net/http" | ||
| 15 | "net/http/cgi" | 17 | "net/http/cgi" | ||
| 16 | "net/netip" | 18 | "net/netip" | ||
| 17 | "regexp" | 19 | "regexp" | ||
| 20 | + | "runtime" | |||
| 18 | "strconv" | 21 | "strconv" | ||
| 19 | "strings" | 22 | "strings" | ||
| 23 | + | "sync" | |||
| 24 | + | "time" | |||
| 20 | 25 | ||||
| 21 | "github.com/bnema/zerowrap" | 26 | "github.com/bnema/zerowrap" | ||
| 22 | 27 | ||||
| @@ -56,59 +61,63 @@ const ( | |||||
| 56 | serviceReceive = "git-receive-pack" | 61 | serviceReceive = "git-receive-pack" | ||
| 57 | ) | 62 | ) | ||
| 58 | 63 | ||||
| 59 | - | // Authenticator turns the secret of a request into a principal. It returns | |||
| 60 | - | // domain.ErrUnauthenticated for a secret that is not valid. app.Auth fits. | |||
| 61 | - | type Authenticator interface { | |||
| 62 | - | Authenticate(ctx context.Context, secret string) (domain.Principal, error) | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // Repos is the read side of the repository use cases. A repository the | |||
| 66 | - | // principal may not read is domain.ErrNotFound. app.Repos fits. | |||
| 67 | - | type Repos interface { | |||
| 68 | - | Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Pushes opens a repository for a push: it is domain.ErrUnauthenticated for an | |||
| 72 | - | // anonymous caller, domain.ErrNotFound for a repository the caller may not | |||
| 73 | - | // write and cannot see (or that is missing and may not be created), | |||
| 74 | - | // domain.ErrForbidden for one it can see but not write. With create, a missing | |||
| 75 | - | // repository the principal may write is created. app.Pushes fits. | |||
| 76 | - | type Pushes interface { | |||
| 77 | - | Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | // HookTokens hands out the per-request tokens the hooks of a push present | |||
| 81 | - | // back to ermite, and the socket they present them on. | |||
| 82 | - | type HookTokens interface { | |||
| 83 | - | // Register binds a new token to the repository and principal of a push. | |||
| 84 | - | // release revokes it and must be called when the request ends. | |||
| 85 | - | Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (token string, release func()) | |||
| 86 | - | // Socket is the path of the hook socket. | |||
| 87 | - | Socket() string | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | // Config configures a Handler. | 64 | // Config configures a Handler. | ||
| 91 | type Config struct { | 65 | type Config struct { | ||
| 92 | - | Auth Authenticator | 66 | + | Auth ports.AuthService |
| 93 | - | Repos Repos | 67 | + | Repos ports.RepoLookup |
| 94 | - | Pushes Pushes | 68 | + | Pushes ports.PushService |
| 95 | Backend ports.GitBackend | 69 | Backend ports.GitBackend | ||
| 96 | // Hooks provides hook tokens for pushes. It is required. | 70 | // Hooks provides hook tokens for pushes. It is required. | ||
| 97 | - | Hooks HookTokens | 71 | + | Hooks ports.HookTokens |
| 98 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | 72 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | ||
| 99 | TrustedProxies []netip.Prefix | 73 | TrustedProxies []netip.Prefix | ||
| 100 | // Limiter counts failed authentications; nil builds a default one. | 74 | // Limiter counts failed authentications; nil builds a default one. | ||
| 101 | - | Limiter *clientlimit.Limiter | 75 | + | Limiter ports.FailureLimiter |
| 102 | // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes. | 76 | // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes. | ||
| 103 | // The body of a fetch is bounded by 64 MiB at most. | 77 | // The body of a fetch is bounded by 64 MiB at most. | ||
| 104 | MaxBodyBytes int64 | 78 | MaxBodyBytes int64 | ||
| 79 | + | // MaxUploadPacks bounds the git-upload-pack backends (fetches, clones, | |||
| 80 | + | // and the ref advertisement that precedes them) running at once; 0 means | |||
| 81 | + | // 2*GOMAXPROCS. | |||
| 82 | + | MaxUploadPacks int | |||
| 83 | + | // MaxReceivePacks bounds the git-receive-pack backends (pushes) running | |||
| 84 | + | // at once; 0 means max(2, GOMAXPROCS/2). | |||
| 85 | + | MaxReceivePacks int | |||
| 86 | + | // MaxPerClient bounds the backends running at once for one client (see | |||
| 87 | + | // clientlimit.Key); 0 means DefaultMaxPerClient. | |||
| 88 | + | MaxPerClient int | |||
| 105 | } | 89 | } | ||
| 106 | 90 | ||||
| 91 | + | // Bounds on the git http-backend processes and on how long a client may take | |||
| 92 | + | // to send a request body. | |||
| 93 | + | const ( | |||
| 94 | + | // DefaultMaxPerClient is the backends one client may run at once. | |||
| 95 | + | DefaultMaxPerClient = 4 | |||
| 96 | + | ||||
| 97 | + | // retryAfter is the Retry-After, in seconds, of a request refused because | |||
| 98 | + | // the backends are busy. | |||
| 99 | + | retryAfter = "5" | |||
| 100 | + | ||||
| 101 | + | // fetchBodyTimeout is the whole time a client has to send the body of a | |||
| 102 | + | // fetch, which is small (64 MiB at most). | |||
| 103 | + | fetchBodyTimeout = 60 * time.Second | |||
| 104 | + | // pushIdleTimeout is how long a push may go without sending a byte. A | |||
| 105 | + | // push is bounded by its size, not by its duration, so the deadline moves | |||
| 106 | + | // forward with every read. | |||
| 107 | + | pushIdleTimeout = 2 * time.Minute | |||
| 108 | + | ) | |||
| 109 | + | ||||
| 107 | // Handler serves the smart HTTP routes of Patterns. | 110 | // Handler serves the smart HTTP routes of Patterns. | ||
| 108 | type Handler struct { | 111 | type Handler struct { | ||
| 109 | cfg Config | 112 | cfg Config | ||
| 110 | - | limiter *clientlimit.Limiter | 113 | + | limiter ports.FailureLimiter |
| 111 | maxBody int64 | 114 | maxBody int64 | ||
| 115 | + | ||||
| 116 | + | upload, receive chan struct{} // slots of the backends, per service | |||
| 117 | + | ||||
| 118 | + | mu sync.Mutex // guards clients | |||
| 119 | + | clients map[string]int // backends running, per clientlimit.Key | |||
| 120 | + | perCli int | |||
| 112 | } | 121 | } | ||
| 113 | 122 | ||||
| 114 | // New builds a Handler. | 123 | // New builds a Handler. | ||
| @@ -120,6 +129,11 @@ func New(cfg Config) *Handler { | |||||
| 120 | if h.maxBody <= 0 { | 129 | if h.maxBody <= 0 { | ||
| 121 | h.maxBody = DefaultMaxBodyBytes | 130 | h.maxBody = DefaultMaxBodyBytes | ||
| 122 | } | 131 | } | ||
| 132 | + | procs := runtime.GOMAXPROCS(0) | |||
| 133 | + | h.upload = make(chan struct{}, cmp.Or(cfg.MaxUploadPacks, 2*procs)) | |||
| 134 | + | h.receive = make(chan struct{}, cmp.Or(cfg.MaxReceivePacks, max(2, procs/2))) | |||
| 135 | + | h.perCli = cmp.Or(cfg.MaxPerClient, DefaultMaxPerClient) | |||
| 136 | + | h.clients = make(map[string]int) | |||
| 123 | return h | 137 | return h | ||
| 124 | } | 138 | } | ||
| 125 | 139 | ||||
| @@ -168,6 +182,8 @@ func parseRoute(r *http.Request) (route, int) { | |||||
| 168 | } | 182 | } | ||
| 169 | 183 | ||||
| 170 | // ServeHTTP implements http.Handler. | 184 | // ServeHTTP implements http.Handler. | ||
| 185 | + | // | |||
| 186 | + | //hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases' | |||
| 171 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 187 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 172 | var rt route | 188 | var rt route | ||
| 173 | var name domain.RepoName | 189 | var name domain.RepoName | ||
| @@ -225,7 +241,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 225 | return | 241 | return | ||
| 226 | } | 242 | } | ||
| 227 | 243 | ||||
| 228 | - | h.serveBackend(w, r, rt, name, principal) | 244 | + | h.serveBackend(w, r, rt, name, principal, clientlimit.Key(addr)) |
| 229 | } | 245 | } | ||
| 230 | 246 | ||||
| 231 | // authenticate reads the credentials of the request. No credentials is the | 247 | // authenticate reads the credentials of the request. No credentials is the | ||
| @@ -311,11 +327,66 @@ var inheritedEnv = []string{ | |||||
| 311 | "LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=", | 327 | "LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=", | ||
| 312 | } | 328 | } | ||
| 313 | 329 | ||||
| 330 | + | // acquire takes a backend slot for the client and the service of rt, or | |||
| 331 | + | // answers 429 (the client already runs its share) or 503 (the server runs all | |||
| 332 | + | // it can) with a Retry-After and reports false. | |||
| 333 | + | // | |||
| 334 | + | // It never waits. A backend can run for minutes, so a queue would only trade | |||
| 335 | + | // a refusal for a held connection and goroutine, and let a burst of requests | |||
| 336 | + | // pile up behind slow clones. Git does not retry by itself, but the refusal is | |||
| 337 | + | // cheap and says when to come back. release must be called when ok. | |||
| 338 | + | func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route, client string) (release func(), ok bool) { | |||
| 339 | + | logger := zerowrap.FromCtx(ctx) | |||
| 340 | + | refuse := func(status int, msg string) { | |||
| 341 | + | w.Header().Set("Retry-After", retryAfter) | |||
| 342 | + | http.Error(w, msg, status) | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | h.mu.Lock() | |||
| 346 | + | if h.clients[client] >= h.perCli { | |||
| 347 | + | h.mu.Unlock() | |||
| 348 | + | logger.Warn().Str("client", client).Msg("too many git requests in progress for the client") | |||
| 349 | + | refuse(http.StatusTooManyRequests, "too many requests in progress") | |||
| 350 | + | return nil, false | |||
| 351 | + | } | |||
| 352 | + | h.clients[client]++ | |||
| 353 | + | h.mu.Unlock() | |||
| 354 | + | releaseClient := func() { | |||
| 355 | + | h.mu.Lock() | |||
| 356 | + | if h.clients[client]--; h.clients[client] <= 0 { | |||
| 357 | + | delete(h.clients, client) | |||
| 358 | + | } | |||
| 359 | + | h.mu.Unlock() | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | slots := h.upload | |||
| 363 | + | if rt.push() { | |||
| 364 | + | slots = h.receive | |||
| 365 | + | } | |||
| 366 | + | select { | |||
| 367 | + | case slots <- struct{}{}: | |||
| 368 | + | return func() { <-slots; releaseClient() }, true | |||
| 369 | + | default: | |||
| 370 | + | releaseClient() | |||
| 371 | + | logger.Warn().Str("service", rt.service).Msg("all git backends busy, request refused") | |||
| 372 | + | refuse(http.StatusServiceUnavailable, "server busy") | |||
| 373 | + | return nil, false | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | ||||
| 314 | // serveBackend runs git http-backend for an authorized request. | 377 | // serveBackend runs git http-backend for an authorized request. | ||
| 315 | - | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal) { | 378 | + | // |
| 379 | + | //hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here | |||
| 380 | + | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) { | |||
| 316 | ctx := r.Context() | 381 | ctx := r.Context() | ||
| 317 | logger := zerowrap.FromCtx(ctx) | 382 | logger := zerowrap.FromCtx(ctx) | ||
| 318 | 383 | ||||
| 384 | + | release, ok := h.acquire(ctx, w, rt, client) | |||
| 385 | + | if !ok { | |||
| 386 | + | return | |||
| 387 | + | } | |||
| 388 | + | defer release() | |||
| 389 | + | ||||
| 319 | be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push()) | 390 | be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push()) | ||
| 320 | if err != nil { | 391 | if err != nil { | ||
| 321 | logger.Error().Err(err).Msg("prepare git http-backend") | 392 | logger.Error().Err(err).Msg("prepare git http-backend") | ||
| @@ -324,8 +395,8 @@ func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, | |||||
| 324 | } | 395 | } | ||
| 325 | env := append(append([]string(nil), be.Env...), inheritedEnv...) | 396 | env := append(append([]string(nil), be.Env...), inheritedEnv...) | ||
| 326 | if rt.suffix == serviceReceive { | 397 | if rt.suffix == serviceReceive { | ||
| 327 | - | token, release := h.cfg.Hooks.Register(ctx, name, p) | 398 | + | token, revoke := h.cfg.Hooks.Register(ctx, name, p) |
| 328 | - | defer release() | 399 | + | defer revoke() |
| 329 | env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token) | 400 | env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token) | ||
| 330 | } | 401 | } | ||
| 331 | 402 | ||||
| @@ -383,7 +454,16 @@ func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt rout | |||||
| 383 | req.Header.Set("Git-Protocol", v) | 454 | req.Header.Set("Git-Protocol", v) | ||
| 384 | } | 455 | } | ||
| 385 | 456 | ||||
| 386 | - | req.Body = http.MaxBytesReader(w, r.Body, limit) | 457 | + | body := &deadlineBody{ |
| 458 | + | ReadCloser: http.MaxBytesReader(w, r.Body, limit), | |||
| 459 | + | rc: http.NewResponseController(w), | |||
| 460 | + | } | |||
| 461 | + | if rt.push() { | |||
| 462 | + | body.idle = pushIdleTimeout | |||
| 463 | + | } else { | |||
| 464 | + | body.until = time.Now().Add(fetchBodyTimeout) | |||
| 465 | + | } | |||
| 466 | + | req.Body = body | |||
| 387 | if r.ContentLength < 0 || len(r.TransferEncoding) > 0 { | 467 | if r.ContentLength < 0 || len(r.TransferEncoding) > 0 { | ||
| 388 | req.ContentLength = -1 | 468 | req.ContentLength = -1 | ||
| 389 | req.TransferEncoding = nil | 469 | req.TransferEncoding = nil | ||
| @@ -391,6 +471,34 @@ func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt rout | |||||
| 391 | return req, 0 | 471 | return req, 0 | ||
| 392 | } | 472 | } | ||
| 393 | 473 | ||||
| 474 | + | // deadlineBody bounds the time a client takes to send a request body, which | |||
| 475 | + | // the server's own timeouts leave unbounded: a client that stalls would hold a | |||
| 476 | + | // backend slot and its process forever. Until the body ends, the connection | |||
| 477 | + | // read deadline is until, or when that is zero, idle after the last read. At | |||
| 478 | + | // the end of the body the deadline is lifted again, so that it does not cut | |||
| 479 | + | // the connection while the response is still being written. | |||
| 480 | + | type deadlineBody struct { | |||
| 481 | + | io.ReadCloser | |||
| 482 | + | rc *http.ResponseController | |||
| 483 | + | idle time.Duration // moves the deadline forward at each read, if non-zero | |||
| 484 | + | until time.Time // fixed deadline, when idle is zero | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func (b *deadlineBody) Read(p []byte) (int, error) { | |||
| 488 | + | d := b.until | |||
| 489 | + | if b.idle > 0 { | |||
| 490 | + | d = time.Now().Add(b.idle) | |||
| 491 | + | } | |||
| 492 | + | // Not every ResponseWriter can set a deadline (HTTP/2 can, wrappers | |||
| 493 | + | // might not): then the body is simply not bounded here. | |||
| 494 | + | _ = b.rc.SetReadDeadline(d) | |||
| 495 | + | n, err := b.ReadCloser.Read(p) | |||
| 496 | + | if err != nil { | |||
| 497 | + | _ = b.rc.SetReadDeadline(time.Time{}) | |||
| 498 | + | } | |||
| 499 | + | return n, err | |||
| 500 | + | } | |||
| 501 | + | ||||
| 394 | // logWriter sends what the Git process (or the CGI layer) writes to its | 502 | // logWriter sends what the Git process (or the CGI layer) writes to its | ||
| 395 | // standard error to the logger of the request. | 503 | // standard error to the logger of the request. | ||
| 396 | type logWriter struct { | 504 | type logWriter struct { | ||
M internal/adapters/githttp/handler_test.go +183 -86
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "strings" | 14 | "strings" | ||
| 15 | "sync" | 15 | "sync" | ||
| 16 | "testing" | 16 | "testing" | ||
| 17 | + | "time" | |||
| 17 | 18 | ||||
| 18 | "github.com/stretchr/testify/assert" | 19 | "github.com/stretchr/testify/assert" | ||
| 19 | "github.com/stretchr/testify/mock" | 20 | "github.com/stretchr/testify/mock" | ||
| @@ -26,97 +27,152 @@ import ( | |||||
| 26 | "bnema.dev/ermite/internal/ports/mocks" | 27 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 27 | ) | 28 | ) | ||
| 28 | 29 | ||||
| 29 | - | type fakeAuth struct { | 30 | + | // cgiScript is a stand-in for git-http-backend: it answers with the |
| 30 | - | mu sync.Mutex | 31 | + | // environment and the body it received. Like Git, it reads the whole request |
| 31 | - | seen []string | 32 | + | // before it answers (net/http cannot read a request body once the response |
| 33 | + | // has started). | |||
| 34 | + | // | |||
| 35 | + | // The script is written once, in TestMain, before any test runs: writing an | |||
| 36 | + | // executable while parallel tests fork lets a child inherit the open write | |||
| 37 | + | // descriptor, and exec then fails with ETXTBSY (golang/go#22315). | |||
| 38 | + | func cgiScript(t *testing.T) string { | |||
| 39 | + | t.Helper() | |||
| 40 | + | require.NotEmpty(t, backendScript, "TestMain writes the backend script") | |||
| 41 | + | return backendScript | |||
| 32 | } | 42 | } | ||
| 33 | 43 | ||||
| 34 | - | func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) { | 44 | + | var backendScript string |
| 35 | - | f.mu.Lock() | 45 | + | |
| 36 | - | f.seen = append(f.seen, secret) | 46 | + | func TestMain(m *testing.M) { |
| 37 | - | f.mu.Unlock() | 47 | + | os.Exit(runWithBackendScript(m)) |
| 38 | - | switch secret { | 48 | + | } |
| 39 | - | case "root-secret": | 49 | + | |
| 40 | - | return domain.Root(), nil | 50 | + | func runWithBackendScript(m *testing.M) int { |
| 41 | - | case "reader-secret": | 51 | + | dir, err := os.MkdirTemp("", "githttp-backend") |
| 42 | - | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | 52 | + | if err != nil { |
| 53 | + | panic(err) | |||
| 54 | + | } | |||
| 55 | + | defer func() { _ = os.RemoveAll(dir) }() | |||
| 56 | + | backendScript = filepath.Join(dir, "backend") | |||
| 57 | + | body := "#!/bin/sh\nin=$(mktemp)\ncat > \"$in\"\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat \"$in\"\nrm -f \"$in\"\n" | |||
| 58 | + | if err := os.WriteFile(backendScript, []byte(body), 0o755); err != nil { | |||
| 59 | + | panic(err) | |||
| 43 | } | 60 | } | ||
| 44 | - | return domain.Principal{}, domain.ErrUnauthenticated | 61 | + | return m.Run() |
| 45 | } | 62 | } | ||
| 46 | 63 | ||||
| 47 | - | type fakeRepos struct{} | 64 | + | type rig struct { |
| 65 | + | h *githttp.Handler | |||
| 66 | + | srv *httptest.Server | |||
| 67 | + | pushes *mocks.PushService | |||
| 68 | + | backend *mocks.GitBackend | |||
| 69 | + | hooks *mocks.HookTokens | |||
| 70 | + | auth *mocks.AuthService | |||
| 48 | 71 | ||||
| 49 | - | func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { | 72 | + | pushFlags []bool |
| 50 | - | switch n.String() { | 73 | + | backendErr error |
| 51 | - | case "pub": | 74 | + | |
| 52 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | 75 | + | mu sync.Mutex // guards what the mocks record while the server runs |
| 53 | - | case "priv": | 76 | + | pushErr error // what Pushes.Open answers a named caller |
| 54 | - | if p.Allows(domain.ScopeRepoRead, n) { | 77 | + | pushCalls []bool // the create flag of each Pushes.Open |
| 55 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | 78 | + | authCalls int // Authenticator.Authenticate calls |
| 56 | - | } | 79 | + | released int // hook tokens released |
| 57 | - | } | 80 | + | hookRepo domain.RepoName |
| 58 | - | return domain.Repo{}, domain.ErrNotFound | |||
| 59 | } | 81 | } | ||
| 60 | 82 | ||||
| 61 | - | type fakePushes struct { | 83 | + | func (r *rig) setPushErr(err error) { |
| 62 | - | mu sync.Mutex | 84 | + | r.mu.Lock() |
| 63 | - | calls []bool // the create flag of each Open | 85 | + | defer r.mu.Unlock() |
| 64 | - | err error | 86 | + | r.pushErr = err |
| 65 | } | 87 | } | ||
| 66 | 88 | ||||
| 67 | - | func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | 89 | + | func (r *rig) takePushCalls() []bool { |
| 68 | - | f.mu.Lock() | 90 | + | r.mu.Lock() |
| 69 | - | defer f.mu.Unlock() | 91 | + | defer r.mu.Unlock() |
| 70 | - | f.calls = append(f.calls, create) | 92 | + | c := r.pushCalls |
| 71 | - | if p.IsAnonymous() { | 93 | + | r.pushCalls = nil |
| 72 | - | return domain.ErrUnauthenticated | 94 | + | return c |
| 73 | - | } | |||
| 74 | - | return f.err | |||
| 75 | } | 95 | } | ||
| 76 | 96 | ||||
| 77 | - | type fakeTokens struct { | 97 | + | func (r *rig) authenticateCalls() int { |
| 78 | - | mu sync.Mutex | 98 | + | r.mu.Lock() |
| 79 | - | released int | 99 | + | defer r.mu.Unlock() |
| 80 | - | repo domain.RepoName | 100 | + | return r.authCalls |
| 81 | } | 101 | } | ||
| 82 | 102 | ||||
| 83 | - | func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) { | 103 | + | func (r *rig) releasedTokens() int { |
| 84 | - | f.mu.Lock() | 104 | + | r.mu.Lock() |
| 85 | - | defer f.mu.Unlock() | 105 | + | defer r.mu.Unlock() |
| 86 | - | f.repo = r | 106 | + | return r.released |
| 87 | - | return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() } | |||
| 88 | } | 107 | } | ||
| 89 | - | func (f *fakeTokens) Socket() string { return "/run/hook.sock" } | |||
| 90 | 108 | ||||
| 91 | - | // cgiScript is a stand-in for git-http-backend: it answers with the | 109 | + | func (r *rig) hookedRepo() domain.RepoName { |
| 92 | - | // environment and the body it received. Like Git, it reads the whole request | 110 | + | r.mu.Lock() |
| 93 | - | // before it answers (net/http cannot read a request body once the response | 111 | + | defer r.mu.Unlock() |
| 94 | - | // has started). | 112 | + | return r.hookRepo |
| 95 | - | func cgiScript(t *testing.T) string { | |||
| 96 | - | t.Helper() | |||
| 97 | - | p := filepath.Join(t.TempDir(), "backend") | |||
| 98 | - | body := "#!/bin/sh\nin=$(mktemp)\ncat > \"$in\"\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat \"$in\"\nrm -f \"$in\"\n" | |||
| 99 | - | require.NoError(t, os.WriteFile(p, []byte(body), 0o755)) | |||
| 100 | - | return p | |||
| 101 | } | 113 | } | ||
| 102 | 114 | ||||
| 103 | - | type rig struct { | 115 | + | // stubUseCases answers like the use cases behind the handler: a root and a |
| 104 | - | h *githttp.Handler | 116 | + | // reader token, a public repository "pub" and a private one "priv". |
| 105 | - | srv *httptest.Server | 117 | + | func (r *rig) stubUseCases(repos *mocks.RepoLookup) { |
| 106 | - | pushes *fakePushes | 118 | + | r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn( |
| 107 | - | backend *mocks.GitBackend | 119 | + | func(_ context.Context, secret string) (domain.Principal, error) { |
| 108 | - | hooks *fakeTokens | 120 | + | r.mu.Lock() |
| 109 | - | auth *fakeAuth | 121 | + | r.authCalls++ |
| 122 | + | r.mu.Unlock() | |||
| 123 | + | switch secret { | |||
| 124 | + | case "root-secret": | |||
| 125 | + | return domain.Root(), nil | |||
| 126 | + | case "reader-secret": | |||
| 127 | + | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | |||
| 128 | + | } | |||
| 129 | + | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 130 | + | }).Maybe() | |||
| 110 | 131 | ||||
| 111 | - | pushFlags []bool | 132 | + | repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 112 | - | backendErr error | 133 | + | func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { |
| 134 | + | switch n.String() { | |||
| 135 | + | case "pub": | |||
| 136 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | |||
| 137 | + | case "priv": | |||
| 138 | + | if p.Allows(domain.ScopeRepoRead, n) { | |||
| 139 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 143 | + | }).Maybe() | |||
| 144 | + | ||||
| 145 | + | r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 146 | + | func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | |||
| 147 | + | r.mu.Lock() | |||
| 148 | + | defer r.mu.Unlock() | |||
| 149 | + | r.pushCalls = append(r.pushCalls, create) | |||
| 150 | + | if p.IsAnonymous() { | |||
| 151 | + | return domain.ErrUnauthenticated | |||
| 152 | + | } | |||
| 153 | + | return r.pushErr | |||
| 154 | + | }).Maybe() | |||
| 155 | + | ||||
| 156 | + | r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 157 | + | func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) { | |||
| 158 | + | r.mu.Lock() | |||
| 159 | + | defer r.mu.Unlock() | |||
| 160 | + | r.hookRepo = n | |||
| 161 | + | return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() } | |||
| 162 | + | }).Maybe() | |||
| 163 | + | r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe() | |||
| 113 | } | 164 | } | ||
| 114 | 165 | ||||
| 115 | func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | 166 | func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | ||
| 116 | t.Helper() | 167 | t.Helper() | ||
| 117 | - | r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}} | 168 | + | r := &rig{ |
| 169 | + | pushes: mocks.NewPushService(t), backend: mocks.NewGitBackend(t), | |||
| 170 | + | hooks: mocks.NewHookTokens(t), auth: mocks.NewAuthService(t), | |||
| 171 | + | } | |||
| 172 | + | repos := mocks.NewRepoLookup(t) | |||
| 173 | + | r.stubUseCases(repos) | |||
| 118 | cfg := githttp.Config{ | 174 | cfg := githttp.Config{ | ||
| 119 | - | Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | 175 | + | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, |
| 120 | } | 176 | } | ||
| 121 | script := cgiScript(t) | 177 | script := cgiScript(t) | ||
| 122 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | 178 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | ||
| @@ -171,7 +227,7 @@ func envOf(body string) map[string]string { | |||||
| 171 | } | 227 | } | ||
| 172 | 228 | ||||
| 173 | func TestHandlerRoutesAndStatuses(t *testing.T) { | 229 | func TestHandlerRoutesAndStatuses(t *testing.T) { | ||
| 174 | - | r := newRig(t, nil) | 230 | + | t.Parallel() |
| 175 | tests := []struct { | 231 | tests := []struct { | ||
| 176 | name string | 232 | name string | ||
| 177 | method string | 233 | method string | ||
| @@ -200,6 +256,8 @@ func TestHandlerRoutesAndStatuses(t *testing.T) { | |||||
| 200 | } | 256 | } | ||
| 201 | for _, tt := range tests { | 257 | for _, tt := range tests { | ||
| 202 | t.Run(tt.name, func(t *testing.T) { | 258 | t.Run(tt.name, func(t *testing.T) { | ||
| 259 | + | t.Parallel() | |||
| 260 | + | r := newRig(t, nil) // a rig per subtest: its mocks record without locking | |||
| 203 | user := "" | 261 | user := "" | ||
| 204 | if tt.pass != "" { | 262 | if tt.pass != "" { | ||
| 205 | user = "whatever" // the username is ignored | 263 | user = "whatever" // the username is ignored | ||
| @@ -216,31 +274,31 @@ func TestHandlerRoutesAndStatuses(t *testing.T) { | |||||
| 216 | } | 274 | } | ||
| 217 | 275 | ||||
| 218 | func TestHandlerPushAuthorizationAnswers(t *testing.T) { | 276 | func TestHandlerPushAuthorizationAnswers(t *testing.T) { | ||
| 277 | + | t.Parallel() | |||
| 219 | r := newRig(t, nil) | 278 | r := newRig(t, nil) | ||
| 220 | get := func() int { | 279 | get := func() int { | ||
| 221 | code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | 280 | code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | ||
| 222 | return code | 281 | return code | ||
| 223 | } | 282 | } | ||
| 224 | assert.Equal(t, 200, get()) | 283 | assert.Equal(t, 200, get()) | ||
| 225 | - | r.pushes.err = domain.ErrForbidden | 284 | + | r.setPushErr(domain.ErrForbidden) |
| 226 | assert.Equal(t, 403, get()) | 285 | assert.Equal(t, 403, get()) | ||
| 227 | - | r.pushes.err = domain.ErrNotFound | 286 | + | r.setPushErr(domain.ErrNotFound) |
| 228 | assert.Equal(t, 404, get()) | 287 | assert.Equal(t, 404, get()) | ||
| 229 | - | r.pushes.err = domain.ErrUnauthenticated | 288 | + | r.setPushErr(domain.ErrUnauthenticated) |
| 230 | assert.Equal(t, 401, get()) | 289 | assert.Equal(t, 401, get()) | ||
| 231 | - | r.pushes.err = errBoom{} | 290 | + | r.setPushErr(errBoom{}) |
| 232 | assert.Equal(t, 500, get()) | 291 | assert.Equal(t, 500, get()) | ||
| 233 | 292 | ||||
| 234 | // Only the first request of a push may create. | 293 | // Only the first request of a push may create. | ||
| 235 | - | r.pushes.err = nil | 294 | + | r.setPushErr(nil) |
| 236 | - | r.pushes.calls = nil | 295 | + | r.takePushCalls() |
| 237 | r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | 296 | r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | ||
| 238 | r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"}) | 297 | r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"}) | ||
| 239 | - | assert.Equal(t, []bool{true, false}, r.pushes.calls) | 298 | + | assert.Equal(t, []bool{true, false}, r.takePushCalls()) |
| 240 | // Reads never reach the push use case. | 299 | // Reads never reach the push use case. | ||
| 241 | - | r.pushes.calls = nil | |||
| 242 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | 300 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | ||
| 243 | - | assert.Empty(t, r.pushes.calls) | 301 | + | assert.Empty(t, r.takePushCalls()) |
| 244 | } | 302 | } | ||
| 245 | 303 | ||||
| 246 | type errBoom struct{} | 304 | type errBoom struct{} | ||
| @@ -248,6 +306,7 @@ type errBoom struct{} | |||||
| 248 | func (errBoom) Error() string { return "boom with /internal/detail" } | 306 | func (errBoom) Error() string { return "boom with /internal/detail" } | ||
| 249 | 307 | ||||
| 250 | func TestHandlerBackendEnvironment(t *testing.T) { | 308 | func TestHandlerBackendEnvironment(t *testing.T) { | ||
| 309 | + | t.Parallel() | |||
| 251 | r := newRig(t, nil) | 310 | r := newRig(t, nil) | ||
| 252 | hdr := map[string]string{ | 311 | hdr := map[string]string{ | ||
| 253 | "Cookie": "session=secret", | 312 | "Cookie": "session=secret", | ||
| @@ -295,21 +354,23 @@ func TestHandlerBackendEnvironment(t *testing.T) { | |||||
| 295 | assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks") | 354 | assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks") | ||
| 296 | assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN") | 355 | assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN") | ||
| 297 | assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR") | 356 | assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR") | ||
| 298 | - | assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends") | 357 | + | assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends") |
| 299 | - | assert.Equal(t, "pub", r.hooks.repo.String()) | 358 | + | assert.Equal(t, "pub", r.hookedRepo().String()) |
| 300 | 359 | ||||
| 301 | // The info/refs advertisement of a push does not register a hook token. | 360 | // The info/refs advertisement of a push does not register a hook token. | ||
| 302 | r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | 361 | r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | ||
| 303 | - | assert.Equal(t, 1, r.hooks.released) | 362 | + | assert.Equal(t, 1, r.releasedTokens()) |
| 304 | } | 363 | } | ||
| 305 | 364 | ||||
| 306 | func TestHandlerNormalizesRepositoryPath(t *testing.T) { | 365 | func TestHandlerNormalizesRepositoryPath(t *testing.T) { | ||
| 366 | + | t.Parallel() | |||
| 307 | r := newRig(t, nil) | 367 | r := newRig(t, nil) | ||
| 308 | _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | 368 | _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | ||
| 309 | assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git") | 369 | assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git") | ||
| 310 | } | 370 | } | ||
| 311 | 371 | ||||
| 312 | func TestHandlerChunkedBodyIsStreamed(t *testing.T) { | 372 | func TestHandlerChunkedBodyIsStreamed(t *testing.T) { | ||
| 373 | + | t.Parallel() | |||
| 313 | r := newRig(t, nil) | 374 | r := newRig(t, nil) | ||
| 314 | payload := strings.Repeat("x", 300_000) | 375 | payload := strings.Repeat("x", 300_000) | ||
| 315 | // An io.Reader of unknown length makes the client send chunked. | 376 | // An io.Reader of unknown length makes the client send chunked. | ||
| @@ -325,6 +386,7 @@ func TestHandlerChunkedBodyIsStreamed(t *testing.T) { | |||||
| 325 | } | 386 | } | ||
| 326 | 387 | ||||
| 327 | func TestHandlerBodyLimit(t *testing.T) { | 388 | func TestHandlerBodyLimit(t *testing.T) { | ||
| 389 | + | t.Parallel() | |||
| 328 | r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 }) | 390 | r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 }) | ||
| 329 | big := strings.Repeat("x", 5000) | 391 | big := strings.Repeat("x", 5000) | ||
| 330 | code, _, _ := r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader(big), nil) | 392 | code, _, _ := r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader(big), nil) | ||
| @@ -342,6 +404,7 @@ func TestHandlerBodyLimit(t *testing.T) { | |||||
| 342 | } | 404 | } | ||
| 343 | 405 | ||||
| 344 | func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) { | 406 | func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) { | ||
| 407 | + | t.Parallel() | |||
| 345 | r := newRig(t, nil) // the push limit is 4 GiB | 408 | r := newRig(t, nil) // the push limit is 4 GiB | ||
| 346 | big := strings.NewReader(strings.Repeat("x", 64<<20+1)) | 409 | big := strings.NewReader(strings.Repeat("x", 64<<20+1)) | ||
| 347 | code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", big, nil) | 410 | code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", big, nil) | ||
| @@ -349,6 +412,7 @@ func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) { | |||||
| 349 | } | 412 | } | ||
| 350 | 413 | ||||
| 351 | func TestHandlerServiceQuery(t *testing.T) { | 414 | func TestHandlerServiceQuery(t *testing.T) { | ||
| 415 | + | t.Parallel() | |||
| 352 | r := newRig(t, nil) | 416 | r := newRig(t, nil) | ||
| 353 | for _, q := range []string{ | 417 | for _, q := range []string{ | ||
| 354 | "service=git-upload-pack&service=git-receive-pack", | 418 | "service=git-upload-pack&service=git-receive-pack", | ||
| @@ -366,6 +430,7 @@ func TestHandlerServiceQuery(t *testing.T) { | |||||
| 366 | } | 430 | } | ||
| 367 | 431 | ||||
| 368 | func TestHandlerGitProtocolHeader(t *testing.T) { | 432 | func TestHandlerGitProtocolHeader(t *testing.T) { | ||
| 433 | + | t.Parallel() | |||
| 369 | r := newRig(t, nil) | 434 | r := newRig(t, nil) | ||
| 370 | tests := []struct { | 435 | tests := []struct { | ||
| 371 | value string | 436 | value string | ||
| @@ -403,6 +468,7 @@ func TestHandlerDoesNotInheritTheProcessEnvironment(t *testing.T) { | |||||
| 403 | } | 468 | } | ||
| 404 | 469 | ||||
| 405 | func TestHandlerBackendFailure(t *testing.T) { | 470 | func TestHandlerBackendFailure(t *testing.T) { | ||
| 471 | + | t.Parallel() | |||
| 406 | r := newRig(t, nil) | 472 | r := newRig(t, nil) | ||
| 407 | r.backendErr = io.ErrUnexpectedEOF | 473 | r.backendErr = io.ErrUnexpectedEOF | ||
| 408 | code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | 474 | code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | ||
| @@ -411,6 +477,7 @@ func TestHandlerBackendFailure(t *testing.T) { | |||||
| 411 | } | 477 | } | ||
| 412 | 478 | ||||
| 413 | func TestHandlerLimiter(t *testing.T) { | 479 | func TestHandlerLimiter(t *testing.T) { | ||
| 480 | + | t.Parallel() | |||
| 414 | r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) }) | 481 | r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) }) | ||
| 415 | for i := range clientlimit.MaxFailures { | 482 | for i := range clientlimit.MaxFailures { | ||
| 416 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil) | 483 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil) | ||
| @@ -420,16 +487,13 @@ func TestHandlerLimiter(t *testing.T) { | |||||
| 420 | assert.Equal(t, 429, code) | 487 | assert.Equal(t, 429, code) | ||
| 421 | assert.NotEmpty(t, hdr.Get("Retry-After")) | 488 | assert.NotEmpty(t, hdr.Get("Retry-After")) | ||
| 422 | // A blocked request does not even reach the authenticator. | 489 | // A blocked request does not even reach the authenticator. | ||
| 423 | - | r.auth.mu.Lock() | 490 | + | n := r.authenticateCalls() |
| 424 | - | n := len(r.auth.seen) | |||
| 425 | - | r.auth.mu.Unlock() | |||
| 426 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil) | 491 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil) | ||
| 427 | - | r.auth.mu.Lock() | 492 | + | assert.Equal(t, n, r.authenticateCalls()) |
| 428 | - | assert.Equal(t, n, len(r.auth.seen)) | |||
| 429 | - | r.auth.mu.Unlock() | |||
| 430 | } | 493 | } | ||
| 431 | 494 | ||||
| 432 | func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | 495 | func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | ||
| 496 | + | t.Parallel() | |||
| 433 | r := newRig(t, func(c *githttp.Config) { | 497 | r := newRig(t, func(c *githttp.Config) { | ||
| 434 | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | 498 | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | ||
| 435 | }) | 499 | }) | ||
| @@ -443,6 +507,7 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |||||
| 443 | } | 507 | } | ||
| 444 | 508 | ||||
| 445 | func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | 509 | func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | ||
| 510 | + | t.Parallel() | |||
| 446 | r := newRig(t, nil) | 511 | r := newRig(t, nil) | ||
| 447 | path := "/priv.git/info/refs?service=git-upload-pack" | 512 | path := "/priv.git/info/refs?service=git-upload-pack" | ||
| 448 | code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"}) | 513 | code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"}) | ||
| @@ -461,3 +526,35 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |||||
| 461 | _ = resp.Body.Close() | 526 | _ = resp.Body.Close() | ||
| 462 | assert.Equal(t, 401, resp.StatusCode) | 527 | assert.Equal(t, 401, resp.StatusCode) | ||
| 463 | } | 528 | } | ||
| 529 | + | ||||
| 530 | + | func TestHandlerUsesTheFailureLimiter(t *testing.T) { | |||
| 531 | + | t.Parallel() | |||
| 532 | + | path := "/pub.git/info/refs?service=git-upload-pack" | |||
| 533 | + | t.Run("a blocked client is refused before authentication", func(t *testing.T) { | |||
| 534 | + | t.Parallel() | |||
| 535 | + | limiter := mocks.NewFailureLimiter(t) | |||
| 536 | + | limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(true, 3*time.Second).Once() | |||
| 537 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter }) | |||
| 538 | + | code, _, hdr := r.do(t, "GET", path, "u", "root-secret", nil, nil) | |||
| 539 | + | assert.Equal(t, 429, code) | |||
| 540 | + | assert.Equal(t, "3", hdr.Get("Retry-After")) | |||
| 541 | + | assert.Zero(t, r.authenticateCalls()) | |||
| 542 | + | }) | |||
| 543 | + | t.Run("an invalid secret is recorded as a failure", func(t *testing.T) { | |||
| 544 | + | t.Parallel() | |||
| 545 | + | limiter := mocks.NewFailureLimiter(t) | |||
| 546 | + | limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(false, time.Duration(0)).Once() | |||
| 547 | + | limiter.EXPECT().Fail(mock.Anything, "127.0.0.1").Once() | |||
| 548 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter }) | |||
| 549 | + | code, _, _ := r.do(t, "GET", path, "u", "wrong", nil, nil) | |||
| 550 | + | assert.Equal(t, 401, code) | |||
| 551 | + | }) | |||
| 552 | + | t.Run("a valid secret is not a failure", func(t *testing.T) { | |||
| 553 | + | t.Parallel() | |||
| 554 | + | limiter := mocks.NewFailureLimiter(t) | |||
| 555 | + | limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(false, time.Duration(0)).Once() | |||
| 556 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter }) | |||
| 557 | + | code, _, _ := r.do(t, "GET", path, "u", "root-secret", nil, nil) | |||
| 558 | + | assert.Equal(t, 200, code) | |||
| 559 | + | }) | |||
| 560 | + | } | |||
M internal/adapters/githttp/hooks.go +6 -12
| @@ -19,17 +19,9 @@ import ( | |||||
| 19 | "github.com/bnema/zerowrap" | 19 | "github.com/bnema/zerowrap" | ||
| 20 | 20 | ||||
| 21 | "bnema.dev/ermite/internal/domain" | 21 | "bnema.dev/ermite/internal/domain" | ||
| 22 | + | "bnema.dev/ermite/internal/ports" | |||
| 22 | ) | 23 | ) | ||
| 23 | 24 | ||||
| 24 | - | // PushHooks is what the hook socket asks of the use cases. app.Pushes fits. | |||
| 25 | - | type PushHooks interface { | |||
| 26 | - | // Check decides a push before Git applies it; an error matching | |||
| 27 | - | // domain.ErrRejected refuses it, any other error refuses it as a failure. | |||
| 28 | - | Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error | |||
| 29 | - | // Record handles an accepted push. | |||
| 30 | - | Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | // readTimeout bounds how long a connection may take to send its request line. | 25 | // readTimeout bounds how long a connection may take to send its request line. | ||
| 34 | const readTimeout = 30 * time.Second | 26 | const readTimeout = 30 * time.Second | ||
| 35 | 27 | ||||
| @@ -46,7 +38,7 @@ type hookEntry struct { | |||||
| 46 | // socket the hooks call back on. It implements HookTokens. | 38 | // socket the hooks call back on. It implements HookTokens. | ||
| 47 | type HookServer struct { | 39 | type HookServer struct { | ||
| 48 | path string | 40 | path string | ||
| 49 | - | hooks PushHooks | 41 | + | hooks ports.PushHookService |
| 50 | 42 | ||||
| 51 | mu sync.RWMutex | 43 | mu sync.RWMutex | ||
| 52 | tokens map[string]hookEntry | 44 | tokens map[string]hookEntry | ||
| @@ -57,10 +49,10 @@ type HookServer struct { | |||||
| 57 | wg sync.WaitGroup | 49 | wg sync.WaitGroup | ||
| 58 | } | 50 | } | ||
| 59 | 51 | ||||
| 60 | - | var _ HookTokens = (*HookServer)(nil) | 52 | + | var _ ports.HookTokens = (*HookServer)(nil) |
| 61 | 53 | ||||
| 62 | // NewHookServer builds a server for the socket at path. Call Serve to listen. | 54 | // NewHookServer builds a server for the socket at path. Call Serve to listen. | ||
| 63 | - | func NewHookServer(path string, hooks PushHooks) *HookServer { | 55 | + | func NewHookServer(path string, hooks ports.PushHookService) *HookServer { |
| 64 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | 56 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | ||
| 65 | } | 57 | } | ||
| 66 | 58 | ||||
| @@ -183,6 +175,8 @@ func (s *HookServer) Close(ctx context.Context) { | |||||
| 183 | } | 175 | } | ||
| 184 | 176 | ||||
| 185 | // handle serves one connection: one request line, one reply line. | 177 | // handle serves one connection: one request line, one reply line. | ||
| 178 | + | // | |||
| 179 | + | //hexcheck:ignore suspicious-business-logic-in-adapter hook protocol dispatch: the decisions are the use cases' | |||
| 186 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | 180 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | ||
| 187 | log := zerowrap.FromCtx(ctx) | 181 | log := zerowrap.FromCtx(ctx) | ||
| 188 | _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) | 182 | _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) | ||
M internal/adapters/githttp/hooks_test.go +60 -92
| @@ -18,10 +18,13 @@ import ( | |||||
| 18 | "time" | 18 | "time" | ||
| 19 | 19 | ||||
| 20 | "github.com/stretchr/testify/assert" | 20 | "github.com/stretchr/testify/assert" | ||
| 21 | + | "github.com/stretchr/testify/mock" | |||
| 21 | "github.com/stretchr/testify/require" | 22 | "github.com/stretchr/testify/require" | ||
| 22 | 23 | ||||
| 23 | "bnema.dev/ermite/internal/adapters/githttp" | 24 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 24 | "bnema.dev/ermite/internal/domain" | 25 | "bnema.dev/ermite/internal/domain" | ||
| 26 | + | "bnema.dev/ermite/internal/ports" | |||
| 27 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 25 | ) | 28 | ) | ||
| 26 | 29 | ||||
| 27 | const ( | 30 | const ( | ||
| @@ -29,46 +32,6 @@ const ( | |||||
| 29 | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | 32 | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | ||
| 30 | ) | 33 | ) | ||
| 31 | 34 | ||||
| 32 | - | type call struct { | |||
| 33 | - | hook string | |||
| 34 | - | p domain.Principal | |||
| 35 | - | repo domain.RepoName | |||
| 36 | - | updates []domain.RefUpdate | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | type fakeHooks struct { | |||
| 40 | - | mu sync.Mutex | |||
| 41 | - | calls []call | |||
| 42 | - | checkErr error | |||
| 43 | - | recErr error | |||
| 44 | - | // onRecord, when set, runs inside Record with its context. | |||
| 45 | - | onRecord func(ctx context.Context) | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |||
| 49 | - | f.mu.Lock() | |||
| 50 | - | defer f.mu.Unlock() | |||
| 51 | - | f.calls = append(f.calls, call{"check", p, n, u}) | |||
| 52 | - | return f.checkErr | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |||
| 56 | - | f.mu.Lock() | |||
| 57 | - | f.calls = append(f.calls, call{"record", p, n, u}) | |||
| 58 | - | err, on := f.recErr, f.onRecord | |||
| 59 | - | f.mu.Unlock() | |||
| 60 | - | if on != nil { | |||
| 61 | - | on(ctx) | |||
| 62 | - | } | |||
| 63 | - | return err | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | func (f *fakeHooks) last() call { | |||
| 67 | - | f.mu.Lock() | |||
| 68 | - | defer f.mu.Unlock() | |||
| 69 | - | return f.calls[len(f.calls)-1] | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | func repoName(t *testing.T, s string) domain.RepoName { | 35 | func repoName(t *testing.T, s string) domain.RepoName { | ||
| 73 | t.Helper() | 36 | t.Helper() | ||
| 74 | n, err := domain.ParseRepoName(s) | 37 | n, err := domain.ParseRepoName(s) | ||
| @@ -77,7 +40,7 @@ func repoName(t *testing.T, s string) domain.RepoName { | |||||
| 77 | } | 40 | } | ||
| 78 | 41 | ||||
| 79 | // startHooks serves a hook socket in a short temporary directory. | 42 | // startHooks serves a hook socket in a short temporary directory. | ||
| 80 | - | func startHooks(t *testing.T, h githttp.PushHooks) (*githttp.HookServer, string) { | 43 | + | func startHooks(t *testing.T, h ports.PushHookService) (*githttp.HookServer, string) { |
| 81 | t.Helper() | 44 | t.Helper() | ||
| 82 | dir, err := os.MkdirTemp("", "hk") | 45 | dir, err := os.MkdirTemp("", "hk") | ||
| 83 | require.NoError(t, err) | 46 | require.NoError(t, err) | ||
| @@ -117,7 +80,8 @@ func raw(t *testing.T, sock, line string) githttp.HookReply { | |||||
| 117 | } | 80 | } | ||
| 118 | 81 | ||||
| 119 | func TestHookSocketMode(t *testing.T) { | 82 | func TestHookSocketMode(t *testing.T) { | ||
| 120 | - | _, sock := startHooks(t, &fakeHooks{}) | 83 | + | t.Parallel() |
| 84 | + | _, sock := startHooks(t, mocks.NewPushHookService(t)) | |||
| 121 | fi, err := os.Stat(sock) | 85 | fi, err := os.Stat(sock) | ||
| 122 | require.NoError(t, err) | 86 | require.NoError(t, err) | ||
| 123 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | 87 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | ||
| @@ -125,26 +89,28 @@ func TestHookSocketMode(t *testing.T) { | |||||
| 125 | } | 89 | } | ||
| 126 | 90 | ||||
| 127 | func TestHookServerReplacesStaleSocketFile(t *testing.T) { | 91 | func TestHookServerReplacesStaleSocketFile(t *testing.T) { | ||
| 92 | + | t.Parallel() | |||
| 128 | dir, err := os.MkdirTemp("", "hk") | 93 | dir, err := os.MkdirTemp("", "hk") | ||
| 129 | require.NoError(t, err) | 94 | require.NoError(t, err) | ||
| 130 | defer func() { _ = os.RemoveAll(dir) }() | 95 | defer func() { _ = os.RemoveAll(dir) }() | ||
| 131 | sock := filepath.Join(dir, "hook.sock") | 96 | sock := filepath.Join(dir, "hook.sock") | ||
| 132 | require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600)) | 97 | require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600)) | ||
| 133 | - | s := githttp.NewHookServer(sock, &fakeHooks{}) | 98 | + | s := githttp.NewHookServer(sock, mocks.NewPushHookService(t)) |
| 134 | require.NoError(t, s.Listen(context.Background())) | 99 | require.NoError(t, s.Listen(context.Background())) | ||
| 135 | s.Close(context.Background()) | 100 | s.Close(context.Background()) | ||
| 136 | } | 101 | } | ||
| 137 | 102 | ||||
| 138 | func TestHookServerRejectsTooLongSocketPath(t *testing.T) { | 103 | func TestHookServerRejectsTooLongSocketPath(t *testing.T) { | ||
| 139 | - | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{}) | 104 | + | t.Parallel() |
| 105 | + | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", mocks.NewPushHookService(t)) | |||
| 140 | err := s.Listen(context.Background()) | 106 | err := s.Listen(context.Background()) | ||
| 141 | require.Error(t, err) | 107 | require.Error(t, err) | ||
| 142 | assert.Contains(t, err.Error(), "too long") | 108 | assert.Contains(t, err.Error(), "too long") | ||
| 143 | } | 109 | } | ||
| 144 | 110 | ||||
| 145 | func TestHookProtocol(t *testing.T) { | 111 | func TestHookProtocol(t *testing.T) { | ||
| 146 | - | fh := &fakeHooks{} | 112 | + | hooks := mocks.NewPushHookService(t) |
| 147 | - | srv, sock := startHooks(t, fh) | 113 | + | srv, sock := startHooks(t, hooks) |
| 148 | p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | 114 | p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | ||
| 149 | tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p) | 115 | tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p) | ||
| 150 | tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root()) | 116 | tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root()) | ||
| @@ -154,46 +120,42 @@ func TestHookProtocol(t *testing.T) { | |||||
| 154 | upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]` | 120 | upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]` | ||
| 155 | 121 | ||||
| 156 | t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) { | 122 | t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) { | ||
| 123 | + | updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}} | |||
| 124 | + | hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }), | |||
| 125 | + | repoName(t, "alpha"), updates).Return(nil).Once() | |||
| 157 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | 126 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | ||
| 158 | assert.True(t, r.OK) | 127 | assert.True(t, r.OK) | ||
| 159 | - | c := fh.last() | |||
| 160 | - | assert.Equal(t, "check", c.hook) | |||
| 161 | - | assert.Equal(t, repoName(t, "alpha"), c.repo) | |||
| 162 | - | assert.Equal(t, "tok1", c.p.TokenID()) | |||
| 163 | - | assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates) | |||
| 164 | 128 | ||||
| 129 | + | hooks.EXPECT().Record(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.IsRoot() }), | |||
| 130 | + | repoName(t, "beta"), updates).Return(nil).Once() | |||
| 165 | r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`) | 131 | r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`) | ||
| 166 | assert.True(t, r.OK) | 132 | assert.True(t, r.OK) | ||
| 167 | - | c = fh.last() | |||
| 168 | - | assert.Equal(t, "record", c.hook) | |||
| 169 | - | assert.Equal(t, repoName(t, "beta"), c.repo) | |||
| 170 | - | assert.True(t, c.p.IsRoot()) | |||
| 171 | }) | 133 | }) | ||
| 172 | 134 | ||||
| 173 | t.Run("a repository sent by the client is ignored", func(t *testing.T) { | 135 | t.Run("a repository sent by the client is ignored", func(t *testing.T) { | ||
| 174 | - | raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) | 136 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once() |
| 175 | - | assert.Equal(t, repoName(t, "alpha"), fh.last().repo) | 137 | + | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) |
| 138 | + | assert.True(t, r.OK) | |||
| 176 | }) | 139 | }) | ||
| 177 | 140 | ||||
| 178 | t.Run("refusal carries the message", func(t *testing.T) { | 141 | t.Run("refusal carries the message", func(t *testing.T) { | ||
| 179 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch")) | 142 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). |
| 180 | - | defer func() { fh.checkErr = nil }() | 143 | + | Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once() |
| 181 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | 144 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | ||
| 182 | assert.False(t, r.OK) | 145 | assert.False(t, r.OK) | ||
| 183 | assert.Contains(t, r.Message, "rejected") | 146 | assert.Contains(t, r.Message, "rejected") | ||
| 184 | }) | 147 | }) | ||
| 185 | 148 | ||||
| 186 | t.Run("an internal error fails closed without leaking details", func(t *testing.T) { | 149 | t.Run("an internal error fails closed without leaking details", func(t *testing.T) { | ||
| 187 | - | fh.checkErr = errors.New("disk /secret/path exploded") | 150 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). |
| 188 | - | defer func() { fh.checkErr = nil }() | 151 | + | Return(errors.New("disk /secret/path exploded")).Once() |
| 189 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | 152 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | ||
| 190 | assert.False(t, r.OK) | 153 | assert.False(t, r.OK) | ||
| 191 | assert.NotContains(t, r.Message, "/secret/path") | 154 | assert.NotContains(t, r.Message, "/secret/path") | ||
| 192 | }) | 155 | }) | ||
| 193 | 156 | ||||
| 194 | t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) { | 157 | t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) { | ||
| 195 | - | fh.recErr = errors.New("boom") | 158 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("boom")).Once() |
| 196 | - | defer func() { fh.recErr = nil }() | |||
| 197 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`) | 159 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`) | ||
| 198 | assert.True(t, r.OK) | 160 | assert.True(t, r.OK) | ||
| 199 | assert.NotEmpty(t, r.Message) | 161 | assert.NotEmpty(t, r.Message) | ||
| @@ -205,18 +167,16 @@ func TestHookProtocol(t *testing.T) { | |||||
| 205 | assert.False(t, r.OK, tok) | 167 | assert.False(t, r.OK, tok) | ||
| 206 | } | 168 | } | ||
| 207 | releaseA() | 169 | releaseA() | ||
| 208 | - | before := len(fh.calls) | |||
| 209 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`) | 170 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`) | ||
| 210 | assert.False(t, r.OK, "released token") | 171 | assert.False(t, r.OK, "released token") | ||
| 211 | assert.Contains(t, r.Message, "token") | 172 | assert.Contains(t, r.Message, "token") | ||
| 212 | - | assert.Len(t, fh.calls, before, "no use case ran") | 173 | + | // The hooks mock expects no call: no use case ran. |
| 213 | releaseB() | 174 | releaseB() | ||
| 214 | }) | 175 | }) | ||
| 215 | 176 | ||||
| 216 | t.Run("malformed requests are refused", func(t *testing.T) { | 177 | t.Run("malformed requests are refused", func(t *testing.T) { | ||
| 217 | tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root()) | 178 | tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root()) | ||
| 218 | defer rel() | 179 | defer rel() | ||
| 219 | - | before := len(fh.calls) | |||
| 220 | for _, line := range []string{ | 180 | for _, line := range []string{ | ||
| 221 | `not json`, | 181 | `not json`, | ||
| 222 | `{}`, | 182 | `{}`, | ||
| @@ -227,7 +187,7 @@ func TestHookProtocol(t *testing.T) { | |||||
| 227 | r := raw(t, sock, line) | 187 | r := raw(t, sock, line) | ||
| 228 | assert.False(t, r.OK, line) | 188 | assert.False(t, r.OK, line) | ||
| 229 | } | 189 | } | ||
| 230 | - | assert.Len(t, fh.calls, before) | 190 | + | // The hooks mock expects no call: no use case ran. |
| 231 | }) | 191 | }) | ||
| 232 | 192 | ||||
| 233 | t.Run("a connection that closes without a request does not hurt", func(t *testing.T) { | 193 | t.Run("a connection that closes without a request does not hurt", func(t *testing.T) { | ||
| @@ -242,24 +202,27 @@ func TestHookProtocol(t *testing.T) { | |||||
| 242 | // A shutdown while a push is being recorded must not drop the recording: the | 202 | // A shutdown while a push is being recorded must not drop the recording: the | ||
| 243 | // push is already in the repository. | 203 | // push is already in the repository. | ||
| 244 | func TestHookRecordSurvivesShutdown(t *testing.T) { | 204 | func TestHookRecordSurvivesShutdown(t *testing.T) { | ||
| 245 | - | fh := &fakeHooks{} | 205 | + | t.Parallel() |
| 206 | + | hooks := mocks.NewPushHookService(t) | |||
| 246 | started, proceed := make(chan struct{}), make(chan struct{}) | 207 | started, proceed := make(chan struct{}), make(chan struct{}) | ||
| 247 | var cancelled, hasDeadline bool | 208 | var cancelled, hasDeadline bool | ||
| 248 | var ctxMu sync.Mutex | 209 | var ctxMu sync.Mutex | ||
| 249 | - | fh.onRecord = func(ctx context.Context) { | 210 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 250 | - | close(started) | 211 | + | func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ []domain.RefUpdate) error { |
| 251 | - | <-proceed | 212 | + | close(started) |
| 252 | - | ctxMu.Lock() | 213 | + | <-proceed |
| 253 | - | defer ctxMu.Unlock() | 214 | + | ctxMu.Lock() |
| 254 | - | cancelled = ctx.Err() != nil | 215 | + | defer ctxMu.Unlock() |
| 255 | - | _, hasDeadline = ctx.Deadline() | 216 | + | cancelled = ctx.Err() != nil |
| 256 | - | } | 217 | + | _, hasDeadline = ctx.Deadline() |
| 218 | + | return nil | |||
| 219 | + | }).Once() | |||
| 257 | 220 | ||||
| 258 | dir, err := os.MkdirTemp("", "hk") | 221 | dir, err := os.MkdirTemp("", "hk") | ||
| 259 | require.NoError(t, err) | 222 | require.NoError(t, err) | ||
| 260 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | 223 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | ||
| 261 | sock := filepath.Join(dir, "hook.sock") | 224 | sock := filepath.Join(dir, "hook.sock") | ||
| 262 | - | srv := githttp.NewHookServer(sock, fh) | 225 | + | srv := githttp.NewHookServer(sock, hooks) |
| 263 | require.NoError(t, srv.Listen(context.Background())) | 226 | require.NoError(t, srv.Listen(context.Background())) | ||
| 264 | ctx, cancel := context.WithCancel(context.Background()) | 227 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 265 | done := make(chan error, 1) | 228 | done := make(chan error, 1) | ||
| @@ -294,9 +257,14 @@ func TestHookRecordSurvivesShutdown(t *testing.T) { | |||||
| 294 | } | 257 | } | ||
| 295 | 258 | ||||
| 296 | func TestHookConcurrentTokens(t *testing.T) { | 259 | func TestHookConcurrentTokens(t *testing.T) { | ||
| 297 | - | fh := &fakeHooks{} | 260 | + | t.Parallel() |
| 298 | - | srv, sock := startHooks(t, fh) | 261 | + | hooks := mocks.NewPushHookService(t) |
| 262 | + | srv, sock := startHooks(t, hooks) | |||
| 299 | var wg sync.WaitGroup | 263 | var wg sync.WaitGroup | ||
| 264 | + | // Each of the 16 tokens reaches the use case for its own repository, once. | |||
| 265 | + | for i := range 16 { | |||
| 266 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "r"+string(rune('a'+i))), mock.Anything).Return(nil).Once() | |||
| 267 | + | } | |||
| 300 | for i := range 16 { | 268 | for i := range 16 { | ||
| 301 | wg.Add(1) | 269 | wg.Add(1) | ||
| 302 | go func() { | 270 | go func() { | ||
| @@ -309,39 +277,35 @@ func TestHookConcurrentTokens(t *testing.T) { | |||||
| 309 | }() | 277 | }() | ||
| 310 | } | 278 | } | ||
| 311 | wg.Wait() | 279 | wg.Wait() | ||
| 312 | - | seen := map[domain.RepoName]bool{} | |||
| 313 | - | for _, c := range fh.calls { | |||
| 314 | - | seen[c.repo] = true | |||
| 315 | - | } | |||
| 316 | - | assert.Len(t, seen, 16) | |||
| 317 | } | 280 | } | ||
| 318 | 281 | ||||
| 319 | func TestRunHook(t *testing.T) { | 282 | func TestRunHook(t *testing.T) { | ||
| 320 | ctx := context.Background() | 283 | ctx := context.Background() | ||
| 321 | - | fh := &fakeHooks{} | 284 | + | hooks := mocks.NewPushHookService(t) |
| 322 | - | srv, sock := startHooks(t, fh) | 285 | + | srv, sock := startHooks(t, hooks) |
| 323 | tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root()) | 286 | tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root()) | ||
| 324 | defer rel() | 287 | defer rel() | ||
| 325 | input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n" | 288 | input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n" | ||
| 326 | 289 | ||||
| 327 | t.Run("sends the lines git feeds the hook", func(t *testing.T) { | 290 | t.Run("sends the lines git feeds the hook", func(t *testing.T) { | ||
| 328 | var errOut bytes.Buffer | 291 | var errOut bytes.Buffer | ||
| 329 | - | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | 292 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{ |
| 330 | - | assert.Equal(t, []domain.RefUpdate{ | |||
| 331 | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}, | 293 | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}, | ||
| 332 | {Old: oidA, New: oidB, Ref: "refs/heads/dev"}, | 294 | {Old: oidA, New: oidB, Ref: "refs/heads/dev"}, | ||
| 333 | - | }, fh.last().updates) | 295 | + | }).Return(nil).Once() |
| 296 | + | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | |||
| 334 | assert.Empty(t, errOut.String()) | 297 | assert.Empty(t, errOut.String()) | ||
| 335 | }) | 298 | }) | ||
| 336 | 299 | ||||
| 337 | t.Run("a ref name with spaces survives", func(t *testing.T) { | 300 | t.Run("a ref name with spaces survives", func(t *testing.T) { | ||
| 301 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"), | |||
| 302 | + | []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once() | |||
| 338 | require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok)) | 303 | require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok)) | ||
| 339 | - | assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref) | |||
| 340 | }) | 304 | }) | ||
| 341 | 305 | ||||
| 342 | t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) { | 306 | t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) { | ||
| 343 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope")) | 307 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). |
| 344 | - | defer func() { fh.checkErr = nil }() | 308 | + | Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once() |
| 345 | var errOut bytes.Buffer | 309 | var errOut bytes.Buffer | ||
| 346 | err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok) | 310 | err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok) | ||
| 347 | require.ErrorIs(t, err, githttp.ErrHookRejected) | 311 | require.ErrorIs(t, err, githttp.ErrHookRejected) | ||
| @@ -400,6 +364,7 @@ func TestRunHook(t *testing.T) { | |||||
| 400 | } | 364 | } | ||
| 401 | 365 | ||||
| 402 | func TestWriteHookScripts(t *testing.T) { | 366 | func TestWriteHookScripts(t *testing.T) { | ||
| 367 | + | t.Parallel() | |||
| 403 | dir := filepath.Join(t.TempDir(), "run", "hooks") | 368 | dir := filepath.Join(t.TempDir(), "run", "hooks") | ||
| 404 | exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite" | 369 | exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite" | ||
| 405 | require.NoError(t, githttp.WriteHookScripts(dir, exe)) | 370 | require.NoError(t, githttp.WriteHookScripts(dir, exe)) | ||
| @@ -425,6 +390,9 @@ func TestWriteHookScripts(t *testing.T) { | |||||
| 425 | } | 390 | } | ||
| 426 | 391 | ||||
| 427 | // The shell really passes the awkward path and the arguments through. | 392 | // The shell really passes the awkward path and the arguments through. | ||
| 393 | + | // | |||
| 394 | + | // Not parallel: it writes executables, and a test that forks meanwhile would | |||
| 395 | + | // inherit the write descriptor and make exec fail with ETXTBSY. | |||
| 428 | func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) { | 396 | func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) { | ||
| 429 | base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z") | 397 | base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z") | ||
| 430 | require.NoError(t, os.MkdirAll(base, 0o755)) | 398 | require.NoError(t, os.MkdirAll(base, 0o755)) | ||
A internal/adapters/githttp/limits_test.go +287 -0
| @@ -0,0 +1,287 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package githttp_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "net/netip" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "testing" | |||
| 14 | + | "time" | |||
| 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/adapters/githttp" | |||
| 21 | + | "bnema.dev/ermite/internal/ports" | |||
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | const ( | |||
| 26 | + | pathUpload = "/pub.git/git-upload-pack" | |||
| 27 | + | pathRefs = "/pub.git/info/refs?service=git-upload-pack" | |||
| 28 | + | pathReceive = "/pub.git/git-receive-pack" | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // held is a request kept inside the backend until the gate opens. | |||
| 32 | + | type held struct{ path, client string } | |||
| 33 | + | ||||
| 34 | + | // gatedRig is a rig whose backend blocks every request that reaches it until | |||
| 35 | + | // open is called, so a test decides exactly which slots are taken. | |||
| 36 | + | type gatedRig struct { | |||
| 37 | + | *rig | |||
| 38 | + | entered chan struct{} // a request reached the backend | |||
| 39 | + | served chan struct{} // a request left the handler, its slots released | |||
| 40 | + | gate chan struct{} | |||
| 41 | + | done sync.WaitGroup | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func newGatedRig(t *testing.T, mut func(*githttp.Config)) *gatedRig { | |||
| 45 | + | t.Helper() | |||
| 46 | + | g := &gatedRig{entered: make(chan struct{}, 64), served: make(chan struct{}, 64), gate: make(chan struct{})} | |||
| 47 | + | script := cgiScript(t) | |||
| 48 | + | be := mocks.NewGitBackend(t) | |||
| 49 | + | be.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | |||
| 50 | + | func(ctx context.Context, _ bool) (ports.HTTPBackend, error) { | |||
| 51 | + | g.entered <- struct{}{} | |||
| 52 | + | select { | |||
| 53 | + | case <-g.gate: | |||
| 54 | + | case <-ctx.Done(): | |||
| 55 | + | } | |||
| 56 | + | return ports.HTTPBackend{Path: script, Dir: filepath.Dir(script), Env: []string{"PATH=/usr/bin:/bin"}}, nil | |||
| 57 | + | }).Maybe() | |||
| 58 | + | g.rig = newRig(t, func(c *githttp.Config) { | |||
| 59 | + | c.Backend = be | |||
| 60 | + | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | |||
| 61 | + | if mut != nil { | |||
| 62 | + | mut(c) | |||
| 63 | + | } | |||
| 64 | + | }) | |||
| 65 | + | // A client sees the response before the handler returns and gives its | |||
| 66 | + | // slots back: serve through a wrapper that tells when it has. | |||
| 67 | + | g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 68 | + | defer func() { g.served <- struct{}{} }() | |||
| 69 | + | g.h.ServeHTTP(w, r) | |||
| 70 | + | })) | |||
| 71 | + | t.Cleanup(g.srv.Close) | |||
| 72 | + | t.Cleanup(func() { g.open(); g.done.Wait() }) | |||
| 73 | + | return g | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // waitServed waits until n requests have left the handler. | |||
| 77 | + | func (g *gatedRig) waitServed(n int) { | |||
| 78 | + | for range n { | |||
| 79 | + | <-g.served | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // hold starts the requests and returns once all of them are inside the backend. | |||
| 84 | + | func (g *gatedRig) hold(t *testing.T, reqs []held) { | |||
| 85 | + | t.Helper() | |||
| 86 | + | for _, h := range reqs { | |||
| 87 | + | method := http.MethodPost | |||
| 88 | + | if h.path == pathRefs { | |||
| 89 | + | method = http.MethodGet | |||
| 90 | + | } | |||
| 91 | + | g.done.Go(func() { | |||
| 92 | + | req, err := http.NewRequest(method, g.srv.URL+h.path, nil) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | req.SetBasicAuth("u", "root-secret") | |||
| 97 | + | req.Header.Set("X-Forwarded-For", h.client) | |||
| 98 | + | if resp, err := http.DefaultClient.Do(req); err == nil { | |||
| 99 | + | _ = resp.Body.Close() | |||
| 100 | + | } | |||
| 101 | + | }) | |||
| 102 | + | } | |||
| 103 | + | for range reqs { | |||
| 104 | + | <-g.entered | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (g *gatedRig) open() { | |||
| 109 | + | select { | |||
| 110 | + | case <-g.gate: | |||
| 111 | + | default: | |||
| 112 | + | close(g.gate) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestHandlerBackendLimits(t *testing.T) { | |||
| 117 | + | t.Parallel() | |||
| 118 | + | tests := []struct { | |||
| 119 | + | name string | |||
| 120 | + | cfg func(*githttp.Config) | |||
| 121 | + | hold []held | |||
| 122 | + | probe held | |||
| 123 | + | want int | |||
| 124 | + | }{ | |||
| 125 | + | { | |||
| 126 | + | name: "free upload slot", | |||
| 127 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 2 }, | |||
| 128 | + | hold: []held{{pathUpload, "198.51.100.1"}}, | |||
| 129 | + | probe: held{pathUpload, "198.51.100.2"}, | |||
| 130 | + | want: http.StatusOK, | |||
| 131 | + | }, | |||
| 132 | + | { | |||
| 133 | + | name: "upload slots full", | |||
| 134 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 2 }, | |||
| 135 | + | hold: []held{{pathUpload, "198.51.100.1"}, {pathRefs, "198.51.100.2"}}, | |||
| 136 | + | probe: held{pathUpload, "198.51.100.3"}, | |||
| 137 | + | want: http.StatusServiceUnavailable, | |||
| 138 | + | }, | |||
| 139 | + | { | |||
| 140 | + | name: "ref advertisement counts as an upload", | |||
| 141 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 1 }, | |||
| 142 | + | hold: []held{{pathUpload, "198.51.100.1"}}, | |||
| 143 | + | probe: held{pathRefs, "198.51.100.2"}, | |||
| 144 | + | want: http.StatusServiceUnavailable, | |||
| 145 | + | }, | |||
| 146 | + | { | |||
| 147 | + | name: "receive slots full", | |||
| 148 | + | cfg: func(c *githttp.Config) { c.MaxReceivePacks = 1 }, | |||
| 149 | + | hold: []held{{pathReceive, "198.51.100.1"}}, | |||
| 150 | + | probe: held{pathReceive, "198.51.100.2"}, | |||
| 151 | + | want: http.StatusServiceUnavailable, | |||
| 152 | + | }, | |||
| 153 | + | { | |||
| 154 | + | name: "pushes do not take upload slots", | |||
| 155 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 1; c.MaxReceivePacks = 1 }, | |||
| 156 | + | hold: []held{{pathUpload, "198.51.100.1"}}, | |||
| 157 | + | probe: held{pathReceive, "198.51.100.2"}, | |||
| 158 | + | want: http.StatusOK, | |||
| 159 | + | }, | |||
| 160 | + | { | |||
| 161 | + | name: "fetches do not take receive slots", | |||
| 162 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 1; c.MaxReceivePacks = 1 }, | |||
| 163 | + | hold: []held{{pathReceive, "198.51.100.1"}}, | |||
| 164 | + | probe: held{pathUpload, "198.51.100.2"}, | |||
| 165 | + | want: http.StatusOK, | |||
| 166 | + | }, | |||
| 167 | + | { | |||
| 168 | + | name: "client at its cap", | |||
| 169 | + | cfg: func(c *githttp.Config) { c.MaxPerClient = 2 }, | |||
| 170 | + | hold: []held{{pathUpload, "198.51.100.1"}, {pathReceive, "198.51.100.1"}}, | |||
| 171 | + | probe: held{pathUpload, "198.51.100.1"}, | |||
| 172 | + | want: http.StatusTooManyRequests, | |||
| 173 | + | }, | |||
| 174 | + | { | |||
| 175 | + | name: "another client is not affected by the cap", | |||
| 176 | + | cfg: func(c *githttp.Config) { c.MaxPerClient = 2 }, | |||
| 177 | + | hold: []held{{pathUpload, "198.51.100.1"}, {pathUpload, "198.51.100.1"}}, | |||
| 178 | + | probe: held{pathUpload, "198.51.100.2"}, | |||
| 179 | + | want: http.StatusOK, | |||
| 180 | + | }, | |||
| 181 | + | { | |||
| 182 | + | name: "addresses of one IPv6 /64 are one client", | |||
| 183 | + | cfg: func(c *githttp.Config) { c.MaxPerClient = 1 }, | |||
| 184 | + | hold: []held{{pathUpload, "2001:db8::1"}}, | |||
| 185 | + | probe: held{pathUpload, "2001:db8::2"}, | |||
| 186 | + | want: http.StatusTooManyRequests, | |||
| 187 | + | }, | |||
| 188 | + | } | |||
| 189 | + | for _, tt := range tests { | |||
| 190 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 191 | + | t.Parallel() | |||
| 192 | + | g := newGatedRig(t, func(c *githttp.Config) { | |||
| 193 | + | c.MaxUploadPacks, c.MaxReceivePacks, c.MaxPerClient = 8, 8, 8 | |||
| 194 | + | tt.cfg(c) | |||
| 195 | + | }) | |||
| 196 | + | g.hold(t, tt.hold) | |||
| 197 | + | if tt.want == http.StatusOK { | |||
| 198 | + | g.open() // the probe has to get through the backend gate too | |||
| 199 | + | } | |||
| 200 | + | code, _, hdr := g.do(t, http.MethodPost, tt.probe.path, "u", "root-secret", nil, | |||
| 201 | + | map[string]string{"X-Forwarded-For": tt.probe.client}) | |||
| 202 | + | if tt.probe.path == pathRefs { | |||
| 203 | + | code, _, hdr = g.do(t, http.MethodGet, tt.probe.path, "u", "root-secret", nil, | |||
| 204 | + | map[string]string{"X-Forwarded-For": tt.probe.client}) | |||
| 205 | + | } | |||
| 206 | + | assert.Equal(t, tt.want, code) | |||
| 207 | + | if tt.want != http.StatusOK { | |||
| 208 | + | assert.NotEmpty(t, hdr.Get("Retry-After")) | |||
| 209 | + | } | |||
| 210 | + | }) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestHandlerBackendSlotsAreReleased(t *testing.T) { | |||
| 215 | + | t.Parallel() | |||
| 216 | + | g := newGatedRig(t, func(c *githttp.Config) { | |||
| 217 | + | c.MaxUploadPacks, c.MaxReceivePacks, c.MaxPerClient = 1, 1, 1 | |||
| 218 | + | }) | |||
| 219 | + | hdr := map[string]string{"X-Forwarded-For": "198.51.100.1"} | |||
| 220 | + | ||||
| 221 | + | g.hold(t, []held{{pathUpload, "198.51.100.1"}}) | |||
| 222 | + | code, _, _ := g.do(t, http.MethodPost, pathUpload, "u", "root-secret", nil, hdr) | |||
| 223 | + | require.Equal(t, http.StatusTooManyRequests, code) | |||
| 224 | + | g.waitServed(1) | |||
| 225 | + | ||||
| 226 | + | g.open() | |||
| 227 | + | g.waitServed(1) | |||
| 228 | + | for _, p := range []string{pathUpload, pathReceive, pathUpload} { | |||
| 229 | + | code, _, _ = g.do(t, http.MethodPost, p, "u", "root-secret", nil, hdr) | |||
| 230 | + | assert.Equal(t, http.StatusOK, code, "the slots came back: %s", p) | |||
| 231 | + | g.waitServed(1) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestHandlerBackendFailureReleasesSlots(t *testing.T) { | |||
| 236 | + | t.Parallel() | |||
| 237 | + | r := newRig(t, func(c *githttp.Config) { c.MaxUploadPacks, c.MaxPerClient = 1, 1 }) | |||
| 238 | + | r.backendErr = context.DeadlineExceeded | |||
| 239 | + | for range 3 { | |||
| 240 | + | code, _, _ := r.do(t, http.MethodPost, pathUpload, "", "", nil, nil) | |||
| 241 | + | assert.Equal(t, http.StatusInternalServerError, code, "never a refusal: the slot is released on failure") | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // deadlineRecorder is a ResponseWriter that supports the read deadline, as | |||
| 246 | + | // the writer of net/http does, and records it. | |||
| 247 | + | type deadlineRecorder struct { | |||
| 248 | + | *httptest.ResponseRecorder | |||
| 249 | + | deadlines []time.Time | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func (d *deadlineRecorder) SetReadDeadline(t time.Time) error { | |||
| 253 | + | d.deadlines = append(d.deadlines, t) | |||
| 254 | + | return nil | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func TestHandlerBodyReadDeadline(t *testing.T) { | |||
| 258 | + | t.Parallel() | |||
| 259 | + | tests := []struct { | |||
| 260 | + | name string | |||
| 261 | + | path string | |||
| 262 | + | max time.Duration | |||
| 263 | + | min time.Duration | |||
| 264 | + | }{ | |||
| 265 | + | {"fetch body", pathUpload, time.Minute, 30 * time.Second}, | |||
| 266 | + | {"push body is bounded by idleness", pathReceive, 5 * time.Minute, time.Minute}, | |||
| 267 | + | } | |||
| 268 | + | for _, tt := range tests { | |||
| 269 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 270 | + | t.Parallel() | |||
| 271 | + | r := newRig(t, nil) | |||
| 272 | + | req := httptest.NewRequest(http.MethodPost, tt.path, stringsReader("payload")) | |||
| 273 | + | req.SetBasicAuth("u", "root-secret") | |||
| 274 | + | w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} | |||
| 275 | + | start := time.Now() | |||
| 276 | + | r.h.ServeHTTP(w, req) | |||
| 277 | + | require.Equal(t, http.StatusOK, w.Code) | |||
| 278 | + | ||||
| 279 | + | require.GreaterOrEqual(t, len(w.deadlines), 2) | |||
| 280 | + | first, last := w.deadlines[0], w.deadlines[len(w.deadlines)-1] | |||
| 281 | + | assert.WithinRange(t, first, start.Add(tt.min), time.Now().Add(tt.max)) | |||
| 282 | + | assert.True(t, last.IsZero(), "the deadline is lifted once the body is read") | |||
| 283 | + | }) | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func stringsReader(s string) *strings.Reader { return strings.NewReader(s) } | |||
M internal/adapters/httpserver/health.go +47 -8
| @@ -3,26 +3,65 @@ | |||||
| 3 | package httpserver | 3 | package httpserver | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 6 | "net/http" | 7 | "net/http" | ||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 7 | 10 | ||||
| 8 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 9 | ) | 12 | ) | ||
| 10 | 13 | ||||
| 14 | + | // healthCacheTTL is how long the result of the checks is reused. The endpoint | |||
| 15 | + | // is unauthenticated and the checks touch the disk and the store: a burst of | |||
| 16 | + | // requests must not turn into a burst of work. | |||
| 17 | + | const healthCacheTTL = 5 * time.Second | |||
| 18 | + | ||||
| 19 | + | // healthCheckTimeout bounds one run of the checks. | |||
| 20 | + | const healthCheckTimeout = 5 * time.Second | |||
| 21 | + | ||||
| 11 | // healthHandler answers 200 "ok" when every check passes and 503 otherwise. | 22 | // healthHandler answers 200 "ok" when every check passes and 503 otherwise. | ||
| 12 | // The failure is logged, never sent: the body stays the same for any cause. | 23 | // The failure is logged, never sent: the body stays the same for any cause. | ||
| 24 | + | // The result, failure included, is kept for healthCacheTTL; concurrent | |||
| 25 | + | // requests wait for the run in progress rather than starting their own. | |||
| 13 | func healthHandler(checks []Check) http.HandlerFunc { | 26 | func healthHandler(checks []Check) http.HandlerFunc { | ||
| 27 | + | var ( | |||
| 28 | + | mu sync.Mutex | |||
| 29 | + | until time.Time // the cached result is valid before this instant | |||
| 30 | + | healthy bool | |||
| 31 | + | ) | |||
| 14 | return func(w http.ResponseWriter, r *http.Request) { | 32 | return func(w http.ResponseWriter, r *http.Request) { | ||
| 33 | + | mu.Lock() | |||
| 34 | + | if !time.Now().Before(until) { | |||
| 35 | + | // The run belongs to every waiting request, not to this one: a | |||
| 36 | + | // client that goes away must not cache a failure. | |||
| 37 | + | ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), healthCheckTimeout) | |||
| 38 | + | healthy = runChecks(ctx, checks) | |||
| 39 | + | cancel() | |||
| 40 | + | until = time.Now().Add(healthCacheTTL) | |||
| 41 | + | } | |||
| 42 | + | ok := healthy | |||
| 43 | + | mu.Unlock() | |||
| 44 | + | ||||
| 15 | w.Header().Set("Cache-Control", "no-store") | 45 | w.Header().Set("Cache-Control", "no-store") | ||
| 16 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | 46 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | ||
| 17 | - | for _, check := range checks { | 47 | + | if !ok { |
| 18 | - | if err := check(r.Context()); err != nil { | 48 | + | w.WriteHeader(http.StatusServiceUnavailable) |
| 19 | - | log := zerowrap.FromCtx(r.Context()) | 49 | + | _, _ = w.Write([]byte("unavailable\n")) |
| 20 | - | log.Warn().Err(err).Msg("health check failed") | 50 | + | return |
| 21 | - | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 22 | - | _, _ = w.Write([]byte("unavailable\n")) | |||
| 23 | - | return | |||
| 24 | - | } | |||
| 25 | } | 51 | } | ||
| 26 | _, _ = w.Write([]byte("ok\n")) | 52 | _, _ = w.Write([]byte("ok\n")) | ||
| 27 | } | 53 | } | ||
| 28 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | // runChecks runs the checks in order and stops at the first failure, which it | |||
| 57 | + | // logs. | |||
| 58 | + | func runChecks(ctx context.Context, checks []Check) bool { | |||
| 59 | + | for _, check := range checks { | |||
| 60 | + | if err := check(ctx); err != nil { | |||
| 61 | + | log := zerowrap.FromCtx(ctx) | |||
| 62 | + | log.Warn().Err(err).Msg("health check failed") | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | return true | |||
| 67 | + | } | |||
M internal/adapters/httpserver/middleware.go +20 -4
| @@ -4,6 +4,7 @@ package httpserver | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "net/http" | 6 | "net/http" | ||
| 7 | + | "net/url" | |||
| 7 | "path" | 8 | "path" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -12,18 +13,33 @@ import ( | |||||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval' | 15 | // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval' | ||
| 15 | - | // for htmx attribute expressions; inline scripts stay forbidden. | 16 | + | // for htmx attribute expressions; inline scripts stay forbidden. Forms post |
| 16 | - | const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | 17 | + | // to the origin only, <base> and plugins are off. |
| 18 | + | const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" | |||
| 19 | + | ||||
| 20 | + | // hstsValue is one year. No includeSubDomains or preload: the server cannot | |||
| 21 | + | // speak for the rest of the domain. | |||
| 22 | + | const hstsValue = "max-age=31536000" | |||
| 23 | + | ||||
| 24 | + | // isHTTPS reports whether the public URL is an https:// URL. | |||
| 25 | + | func isHTTPS(publicURL string) bool { | |||
| 26 | + | u, err := url.Parse(publicURL) | |||
| 27 | + | return err == nil && strings.EqualFold(u.Scheme, "https") | |||
| 28 | + | } | |||
| 17 | 29 | ||||
| 18 | // withSecurityHeaders sets the security headers on every response, before | 30 | // withSecurityHeaders sets the security headers on every response, before | ||
| 19 | - | // the handler runs, so that errors and 404s carry them too. | 31 | + | // the handler runs, so that errors and 404s carry them too. With hsts, the |
| 20 | - | func withSecurityHeaders(next http.Handler) http.Handler { | 32 | + | // browser is told to stay on https. |
| 33 | + | func withSecurityHeaders(next http.Handler, hsts bool) http.Handler { | |||
| 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 34 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 22 | h := w.Header() | 35 | h := w.Header() | ||
| 23 | h.Set("Content-Security-Policy", contentSecurityPolicy) | 36 | h.Set("Content-Security-Policy", contentSecurityPolicy) | ||
| 24 | h.Set("X-Frame-Options", "DENY") | 37 | h.Set("X-Frame-Options", "DENY") | ||
| 25 | h.Set("X-Content-Type-Options", "nosniff") | 38 | h.Set("X-Content-Type-Options", "nosniff") | ||
| 26 | h.Set("Referrer-Policy", "same-origin") | 39 | h.Set("Referrer-Policy", "same-origin") | ||
| 40 | + | if hsts { | |||
| 41 | + | h.Set("Strict-Transport-Security", hstsValue) | |||
| 42 | + | } | |||
| 27 | next.ServeHTTP(w, r) | 43 | next.ServeHTTP(w, r) | ||
| 28 | }) | 44 | }) | ||
| 29 | } | 45 | } | ||
M internal/adapters/httpserver/server.go +7 -2
| @@ -36,7 +36,12 @@ type Route struct { | |||||
| 36 | type Config struct { | 36 | type Config struct { | ||
| 37 | // Addr is the listen address, "host:port". | 37 | // Addr is the listen address, "host:port". | ||
| 38 | Addr string | 38 | Addr string | ||
| 39 | - | // HealthChecks run on every GET /-/health; one failure answers 503. | 39 | + | // PublicURL is the public base URL of the server. When it is https://, |
| 40 | + | // every response carries Strict-Transport-Security. | |||
| 41 | + | PublicURL string | |||
| 42 | + | // HealthChecks run on GET /-/health, at most once per healthCacheTTL | |||
| 43 | + | // (the answer is cached, the endpoint being unauthenticated); one | |||
| 44 | + | // failure answers 503. | |||
| 40 | HealthChecks []Check | 45 | HealthChecks []Check | ||
| 41 | // Routes are added to the router next to /-/health. | 46 | // Routes are added to the router next to /-/health. | ||
| 42 | Routes []Route | 47 | Routes []Route | ||
| @@ -71,7 +76,7 @@ func New(cfg Config) *Server { | |||||
| 71 | for _, r := range cfg.Routes { | 76 | for _, r := range cfg.Routes { | ||
| 72 | mux.Handle(r.Pattern, r.Handler) | 77 | mux.Handle(r.Pattern, r.Handler) | ||
| 73 | } | 78 | } | ||
| 74 | - | return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux)), onShutdown: cfg.OnShutdown} | 79 | + | return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL))), onShutdown: cfg.OnShutdown} |
| 75 | } | 80 | } | ||
| 76 | 81 | ||||
| 77 | // Handler returns the root handler, for tests. | 82 | // Handler returns the root handler, for tests. | ||
M internal/adapters/httpserver/server_test.go +76 -1
| @@ -14,7 +14,9 @@ import ( | |||||
| 14 | "os" | 14 | "os" | ||
| 15 | "strings" | 15 | "strings" | ||
| 16 | "sync" | 16 | "sync" | ||
| 17 | + | "sync/atomic" | |||
| 17 | "testing" | 18 | "testing" | ||
| 19 | + | "testing/synctest" | |||
| 18 | "time" | 20 | "time" | ||
| 19 | 21 | ||||
| 20 | "github.com/bnema/zerowrap" | 22 | "github.com/bnema/zerowrap" | ||
| @@ -25,7 +27,7 @@ import ( | |||||
| 25 | ) | 27 | ) | ||
| 26 | 28 | ||||
| 27 | var securityHeaders = map[string]string{ | 29 | var securityHeaders = map[string]string{ | ||
| 28 | - | "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'", | 30 | + | "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'", |
| 29 | "X-Frame-Options": "DENY", | 31 | "X-Frame-Options": "DENY", | ||
| 30 | "X-Content-Type-Options": "nosniff", | 32 | "X-Content-Type-Options": "nosniff", | ||
| 31 | "Referrer-Policy": "same-origin", | 33 | "Referrer-Policy": "same-origin", | ||
| @@ -76,6 +78,79 @@ func TestHealth(t *testing.T) { | |||||
| 76 | } | 78 | } | ||
| 77 | } | 79 | } | ||
| 78 | 80 | ||||
| 81 | + | func TestHealthIsCached(t *testing.T) { | |||
| 82 | + | synctest.Test(t, func(t *testing.T) { | |||
| 83 | + | var calls atomic.Int32 | |||
| 84 | + | var failing atomic.Bool | |||
| 85 | + | check := httpserver.Check(func(context.Context) error { | |||
| 86 | + | calls.Add(1) | |||
| 87 | + | if failing.Load() { | |||
| 88 | + | return errors.New("down") | |||
| 89 | + | } | |||
| 90 | + | return nil | |||
| 91 | + | }) | |||
| 92 | + | h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler() | |||
| 93 | + | ||||
| 94 | + | steps := []struct { | |||
| 95 | + | name string | |||
| 96 | + | sleep time.Duration | |||
| 97 | + | fail bool | |||
| 98 | + | wantStatus int | |||
| 99 | + | wantCalls int32 | |||
| 100 | + | }{ | |||
| 101 | + | {"first request runs the checks", 0, false, 200, 1}, | |||
| 102 | + | {"a burst reuses the result", 4 * time.Second, true, 200, 1}, | |||
| 103 | + | {"after the TTL the checks run again", time.Second, true, 503, 2}, | |||
| 104 | + | {"a failure is cached too", time.Second, false, 503, 2}, | |||
| 105 | + | {"and recovers after the TTL", 4 * time.Second, false, 200, 3}, | |||
| 106 | + | } | |||
| 107 | + | for _, st := range steps { | |||
| 108 | + | time.Sleep(st.sleep) | |||
| 109 | + | failing.Store(st.fail) | |||
| 110 | + | rec := do(t, h, "GET", "/-/health") | |||
| 111 | + | assert.Equal(t, st.wantStatus, rec.Code, st.name) | |||
| 112 | + | assert.Equal(t, st.wantCalls, calls.Load(), st.name) | |||
| 113 | + | } | |||
| 114 | + | }) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestHealthConcurrentRequestsShareOneRun(t *testing.T) { | |||
| 118 | + | var calls atomic.Int32 | |||
| 119 | + | check := httpserver.Check(func(context.Context) error { calls.Add(1); return nil }) | |||
| 120 | + | h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler() | |||
| 121 | + | ||||
| 122 | + | var wg sync.WaitGroup | |||
| 123 | + | for range 20 { | |||
| 124 | + | wg.Go(func() { assert.Equal(t, 200, do(t, h, "GET", "/-/health").Code) }) | |||
| 125 | + | } | |||
| 126 | + | wg.Wait() | |||
| 127 | + | assert.Equal(t, int32(1), calls.Load()) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestStrictTransportSecurity(t *testing.T) { | |||
| 131 | + | tests := []struct { | |||
| 132 | + | name string | |||
| 133 | + | publicURL string | |||
| 134 | + | want string | |||
| 135 | + | }{ | |||
| 136 | + | {"https", "https://forge.example.com", "max-age=31536000"}, | |||
| 137 | + | {"https uppercase scheme", "HTTPS://forge.example.com", "max-age=31536000"}, | |||
| 138 | + | {"http", "http://forge.example.com", ""}, | |||
| 139 | + | {"http loopback", "http://127.0.0.1:7341", ""}, | |||
| 140 | + | {"unset", "", ""}, | |||
| 141 | + | {"garbage", "://%", ""}, | |||
| 142 | + | } | |||
| 143 | + | for _, tt := range tests { | |||
| 144 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 145 | + | h := httpserver.New(httpserver.Config{PublicURL: tt.publicURL}).Handler() | |||
| 146 | + | for _, target := range []string{"/-/health", "/nope"} { | |||
| 147 | + | rec := do(t, h, "GET", target) | |||
| 148 | + | assert.Equal(t, tt.want, rec.Header().Get("Strict-Transport-Security"), target) | |||
| 149 | + | } | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 79 | func TestHeadOverTheWire(t *testing.T) { | 154 | func TestHeadOverTheWire(t *testing.T) { | ||
| 80 | ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler()) | 155 | ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler()) | ||
| 81 | defer ts.Close() | 156 | defer ts.Close() | ||
M internal/adapters/kvstore/store.go +14 -2
| @@ -58,9 +58,21 @@ func (s *Store) Close(ctx context.Context) error { | |||||
| 58 | 58 | ||||
| 59 | // Check reports whether the database accepts writes. It returns the failure | 59 | // Check reports whether the database accepts writes. It returns the failure | ||
| 60 | // once ermite-kv is closed or failed, and writes nothing. | 60 | // once ermite-kv is closed or failed, and writes nothing. | ||
| 61 | + | // | |||
| 62 | + | // ermite-kv has no state accessor, and an empty batch sent through Apply would | |||
| 63 | + | // queue behind the writes in progress. Apply looks at the state of the database | |||
| 64 | + | // before it looks at its context, though, and an ended context stops it before | |||
| 65 | + | // the writer channel: Apply on a cancelled context therefore answers ErrClosed | |||
| 66 | + | // or ErrFailed when the database is stopped, and context.Canceled when it is | |||
| 67 | + | // healthy, without waiting for the writer. | |||
| 61 | func (s *Store) Check(ctx context.Context) error { | 68 | func (s *Store) Check(ctx context.Context) error { | ||
| 62 | - | var b kv.Batch // no ops: only the state of the database is checked | 69 | + | if err := ctx.Err(); err != nil { |
| 63 | - | if _, err := s.db.Apply(ctx, &b); err != nil { | 70 | + | return err |
| 71 | + | } | |||
| 72 | + | probe, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |||
| 73 | + | cancel() | |||
| 74 | + | var b kv.Batch | |||
| 75 | + | if _, err := s.db.Apply(probe, &b); err != nil && !errors.Is(err, context.Canceled) { | |||
| 64 | return fmt.Errorf("ermite-kv: %w", err) | 76 | return fmt.Errorf("ermite-kv: %w", err) | ||
| 65 | } | 77 | } | ||
| 66 | return nil | 78 | return nil | ||
M internal/adapters/kvstore/store_test.go +6 -0
| @@ -144,6 +144,12 @@ func TestCheck(t *testing.T) { | |||||
| 144 | s, err := kvstore.Open(ctx, t.TempDir()) | 144 | s, err := kvstore.Open(ctx, t.TempDir()) | ||
| 145 | require.NoError(t, err) | 145 | require.NoError(t, err) | ||
| 146 | require.NoError(t, s.Check(ctx)) | 146 | require.NoError(t, s.Check(ctx)) | ||
| 147 | + | ||||
| 148 | + | // The caller's own cancellation is reported, not mistaken for health. | |||
| 149 | + | cancelled, cancel := context.WithCancel(ctx) | |||
| 150 | + | cancel() | |||
| 151 | + | assert.ErrorIs(t, s.Check(cancelled), context.Canceled) | |||
| 152 | + | ||||
| 147 | require.NoError(t, s.Close(ctx)) | 153 | require.NoError(t, s.Close(ctx)) | ||
| 148 | assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed) | 154 | assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed) | ||
| 149 | } | 155 | } | ||
M internal/adapters/rpc/api.go +18 -22
| @@ -2,9 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with | 3 | // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with | ||
| 4 | // ConnectRPC: handlers that map the messages of the contract to the use cases | 4 | // ConnectRPC: handlers that map the messages of the contract to the use cases | ||
| 5 | - | // of app and back, the interceptor that authenticates callers, and the error | 5 | + | // of usecase and back, the interceptor that authenticates callers, and the error |
| 6 | - | // mapping. The same handlers serve remote clients over HTTP and the web in | 6 | + | // mapping. |
| 7 | - | // process (see InProcessClient). | |||
| 8 | package rpc | 7 | package rpc | ||
| 9 | 8 | ||||
| 10 | import ( | 9 | import ( | ||
| @@ -16,9 +15,9 @@ import ( | |||||
| 16 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 17 | 16 | ||||
| 18 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 17 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | ||
| 19 | - | "bnema.dev/ermite/internal/app" | |||
| 20 | "bnema.dev/ermite/internal/clientlimit" | 18 | "bnema.dev/ermite/internal/clientlimit" | ||
| 21 | "bnema.dev/ermite/internal/domain" | 19 | "bnema.dev/ermite/internal/domain" | ||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 22 | ) | 21 | ) | ||
| 23 | 22 | ||||
| 24 | // MaxRequestBytes bounds the size of a request message. Responses are not | 23 | // MaxRequestBytes bounds the size of a request message. Responses are not | ||
| @@ -29,31 +28,28 @@ const MaxRequestBytes = 1 << 20 | |||||
| 29 | // implements, as GetVersion lists them. | 28 | // implements, as GetVersion lists them. | ||
| 30 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | 29 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | ||
| 31 | 30 | ||||
| 32 | - | // Authenticator turns a token secret into a principal, or | |||
| 33 | - | // domain.ErrUnauthenticated. app.Auth fits. | |||
| 34 | - | type Authenticator interface { | |||
| 35 | - | Authenticate(ctx context.Context, secret string) (domain.Principal, error) | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | // Config configures the API. | 31 | // Config configures the API. | ||
| 39 | type Config struct { | 32 | type Config struct { | ||
| 40 | - | Auth Authenticator | 33 | + | Auth ports.AuthService |
| 41 | - | Repos *app.Repos | 34 | + | Repos ports.RepoService |
| 42 | - | Tokens *app.Tokens | 35 | + | Tokens ports.TokenService |
| 43 | - | Browse *app.Browse | 36 | + | Browse ports.BrowseService |
| 44 | - | Events *app.Events | 37 | + | Events ports.EventService |
| 45 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | 38 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | ||
| 46 | - | MergeRequests *app.MergeRequests | 39 | + | MergeRequests ports.MergeRequestService |
| 47 | - | SigningKey *app.SigningKey | 40 | + | SigningKey ports.SigningKeyService |
| 48 | Version string | 41 | Version string | ||
| 49 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | 42 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | ||
| 50 | TrustedProxies []netip.Prefix | 43 | TrustedProxies []netip.Prefix | ||
| 51 | // Limiter counts failed authentications; nil builds a default one. Share | 44 | // Limiter counts failed authentications; nil builds a default one. Share | ||
| 52 | // it with the other adapters that authenticate, so that a client has one | 45 | // it with the other adapters that authenticate, so that a client has one | ||
| 53 | - | // budget of failures. | 46 | + | // budget of failures. Leave it unset rather than assigning a typed nil |
| 54 | - | Limiter *clientlimit.Limiter | 47 | + | // pointer (a nil *T in the interface is not nil, and would be called). |
| 48 | + | Limiter ports.FailureLimiter | |||
| 55 | // Sessions are the owner's web sessions; nil serves no SessionService. | 49 | // Sessions are the owner's web sessions; nil serves no SessionService. | ||
| 56 | - | Sessions *app.Sessions | 50 | + | // Leave it unset rather than assigning a typed nil pointer (a nil *T in |
| 51 | + | // the interface is not nil, and the service would be served and called). | |||
| 52 | + | Sessions ports.SessionService | |||
| 57 | } | 53 | } | ||
| 58 | 54 | ||||
| 59 | // API is the set of Connect handlers of the contract. | 55 | // API is the set of Connect handlers of the contract. | ||
| @@ -93,8 +89,8 @@ func New(cfg Config) *API { | |||||
| 93 | // it under /-/api/ and strips the prefix. | 89 | // it under /-/api/ and strips the prefix. | ||
| 94 | func (a *API) Handler() http.Handler { return a.handler } | 90 | func (a *API) Handler() http.Handler { return a.handler } | ||
| 95 | 91 | ||||
| 96 | - | // caller returns the principal the interceptor (or the in-process caller) | 92 | + | // caller returns the principal the interceptor put in ctx; anonymous when |
| 97 | - | // put in ctx; anonymous when there is none. | 93 | + | // there is none. |
| 98 | func caller(ctx context.Context) domain.Principal { | 94 | func caller(ctx context.Context) domain.Principal { | ||
| 99 | p, _ := domain.PrincipalFromContext(ctx) | 95 | p, _ := domain.PrincipalFromContext(ctx) | ||
| 100 | return p | 96 | return p | ||
M internal/adapters/rpc/convert.go +4 -4
| @@ -9,8 +9,8 @@ import ( | |||||
| 9 | "google.golang.org/protobuf/types/known/timestamppb" | 9 | "google.golang.org/protobuf/types/known/timestamppb" | ||
| 10 | 10 | ||||
| 11 | pb "bnema.dev/ermite-api/gen/ermite" | 11 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 12 | - | "bnema.dev/ermite/internal/app" | |||
| 13 | "bnema.dev/ermite/internal/domain" | 12 | "bnema.dev/ermite/internal/domain" | ||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // This file maps domain values to the messages of the contract and back. | 16 | // This file maps domain values to the messages of the contract and back. | ||
| @@ -55,7 +55,7 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) { | |||||
| 55 | return "", fmt.Errorf("%w: %v", domain.ErrInvalidVisibility, v) | 55 | return "", fmt.Errorf("%w: %v", domain.ErrInvalidVisibility, v) | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | - | func repoToPB(v app.RepoView) *pb.Repo { | 58 | + | func repoToPB(v ports.RepoView) *pb.Repo { |
| 59 | settings := v.Settings | 59 | settings := v.Settings | ||
| 60 | if settings.IsZero() { | 60 | if settings.IsZero() { | ||
| 61 | settings = domain.DefaultRepoSettings() | 61 | settings = domain.DefaultRepoSettings() | ||
| @@ -361,7 +361,7 @@ func refUpdatesToPB(updates []domain.RefUpdate) []*pb.RefUpdate { | |||||
| 361 | 361 | ||||
| 362 | // watchEventToPB maps one item of a watch; nil when the event has no | 362 | // watchEventToPB maps one item of a watch; nil when the event has no | ||
| 363 | // representation in the contract. | 363 | // representation in the contract. | ||
| 364 | - | func watchEventToPB(we app.WatchEvent) *pb.Event { | 364 | + | func watchEventToPB(we ports.WatchEvent) *pb.Event { |
| 365 | if we.Gap { | 365 | if we.Gap { | ||
| 366 | return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | 366 | return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | ||
| 367 | } | 367 | } | ||
| @@ -397,7 +397,7 @@ func watchEventToPB(we app.WatchEvent) *pb.Event { | |||||
| 397 | return out | 397 | return out | ||
| 398 | } | 398 | } | ||
| 399 | 399 | ||||
| 400 | - | func repoOrName(we app.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo { | 400 | + | func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo { |
| 401 | if we.Repo != nil { | 401 | if we.Repo != nil { | ||
| 402 | return repoToPB(*we.Repo) | 402 | return repoToPB(*we.Repo) | ||
| 403 | } | 403 | } | ||
M internal/adapters/rpc/convert_test.go +21 -5
| @@ -15,15 +15,17 @@ import ( | |||||
| 15 | "google.golang.org/protobuf/types/known/fieldmaskpb" | 15 | "google.golang.org/protobuf/types/known/fieldmaskpb" | ||
| 16 | 16 | ||||
| 17 | pb "bnema.dev/ermite-api/gen/ermite" | 17 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 18 | - | "bnema.dev/ermite/internal/app" | |||
| 19 | "bnema.dev/ermite/internal/domain" | 18 | "bnema.dev/ermite/internal/domain" | ||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | // Every value of the domain maps to a real value of the contract, never to | 22 | // Every value of the domain maps to a real value of the contract, never to | ||
| 23 | // UNSPECIFIED, and the mappers that have an inverse round-trip. A value added | 23 | // UNSPECIFIED, and the mappers that have an inverse round-trip. A value added | ||
| 24 | // to the domain and forgotten here would reach clients as "not set". | 24 | // to the domain and forgotten here would reach clients as "not set". | ||
| 25 | func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | 25 | func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | ||
| 26 | + | t.Parallel() | |||
| 26 | t.Run("visibility", func(t *testing.T) { | 27 | t.Run("visibility", func(t *testing.T) { | ||
| 28 | + | t.Parallel() | |||
| 27 | for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} { | 29 | for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} { | ||
| 28 | got := visibilityToPB(v) | 30 | got := visibilityToPB(v) | ||
| 29 | assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v) | 31 | assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v) | ||
| @@ -37,6 +39,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||||
| 37 | }) | 39 | }) | ||
| 38 | 40 | ||||
| 39 | t.Run("scope", func(t *testing.T) { | 41 | t.Run("scope", func(t *testing.T) { | ||
| 42 | + | t.Parallel() | |||
| 40 | for _, s := range domain.AllScopes() { | 43 | for _, s := range domain.AllScopes() { | ||
| 41 | got, ok := scopeToPB[s] | 44 | got, ok := scopeToPB[s] | ||
| 42 | require.True(t, ok, "scope %s has no counterpart in the contract", s) | 45 | require.True(t, ok, "scope %s has no counterpart in the contract", s) | ||
| @@ -58,6 +61,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||||
| 58 | }) | 61 | }) | ||
| 59 | 62 | ||||
| 60 | t.Run("ref kind", func(t *testing.T) { | 63 | t.Run("ref kind", func(t *testing.T) { | ||
| 64 | + | t.Parallel() | |||
| 61 | for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} { | 65 | for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} { | ||
| 62 | got := refKindToPB(k) | 66 | got := refKindToPB(k) | ||
| 63 | assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k) | 67 | assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k) | ||
| @@ -70,6 +74,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||||
| 70 | }) | 74 | }) | ||
| 71 | 75 | ||||
| 72 | t.Run("entry kind", func(t *testing.T) { | 76 | t.Run("entry kind", func(t *testing.T) { | ||
| 77 | + | t.Parallel() | |||
| 73 | seen := map[pb.EntryKind]domain.EntryKind{} | 78 | seen := map[pb.EntryKind]domain.EntryKind{} | ||
| 74 | for _, k := range []domain.EntryKind{ | 79 | for _, k := range []domain.EntryKind{ | ||
| 75 | domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule, | 80 | domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule, | ||
| @@ -82,6 +87,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||||
| 82 | }) | 87 | }) | ||
| 83 | 88 | ||||
| 84 | t.Run("file status", func(t *testing.T) { | 89 | t.Run("file status", func(t *testing.T) { | ||
| 90 | + | t.Parallel() | |||
| 85 | seen := map[pb.FileStatus]domain.FileStatus{} | 91 | seen := map[pb.FileStatus]domain.FileStatus{} | ||
| 86 | for _, s := range []domain.FileStatus{ | 92 | for _, s := range []domain.FileStatus{ | ||
| 87 | domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged, | 93 | domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged, | ||
| @@ -94,6 +100,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||||
| 94 | }) | 100 | }) | ||
| 95 | 101 | ||||
| 96 | t.Run("line kind", func(t *testing.T) { | 102 | t.Run("line kind", func(t *testing.T) { | ||
| 103 | + | t.Parallel() | |||
| 97 | seen := map[pb.LineKind]domain.LineKind{} | 104 | seen := map[pb.LineKind]domain.LineKind{} | ||
| 98 | for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} { | 105 | for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} { | ||
| 99 | got := lineKindToPB(k) | 106 | got := lineKindToPB(k) | ||
| @@ -106,6 +113,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||||
| 106 | // Every value of the contract except UNSPECIFIED is produced by some value | 113 | // Every value of the contract except UNSPECIFIED is produced by some value | ||
| 107 | // of the domain: nothing in the contract is unreachable. | 114 | // of the domain: nothing in the contract is unreachable. | ||
| 108 | t.Run("the contract has no value the domain cannot produce", func(t *testing.T) { | 115 | t.Run("the contract has no value the domain cannot produce", func(t *testing.T) { | ||
| 116 | + | t.Parallel() | |||
| 109 | produced := func(names map[int32]string, from func(v int32) bool) { | 117 | produced := func(names map[int32]string, from func(v int32) bool) { | ||
| 110 | for v, n := range names { | 118 | for v, n := range names { | ||
| 111 | if v == 0 { | 119 | if v == 0 { | ||
| @@ -142,6 +150,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||||
| 142 | } | 150 | } | ||
| 143 | 151 | ||||
| 144 | func TestMergeModeMappers(t *testing.T) { | 152 | func TestMergeModeMappers(t *testing.T) { | ||
| 153 | + | t.Parallel() | |||
| 145 | for _, m := range domain.MergeModes() { | 154 | for _, m := range domain.MergeModes() { | ||
| 146 | got := mergeModeToPB(m) | 155 | got := mergeModeToPB(m) | ||
| 147 | assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m) | 156 | assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m) | ||
| @@ -168,6 +177,7 @@ func TestMergeModeMappers(t *testing.T) { | |||||
| 168 | } | 177 | } | ||
| 169 | 178 | ||||
| 170 | func TestMergeRequestEnumMappers(t *testing.T) { | 179 | func TestMergeRequestEnumMappers(t *testing.T) { | ||
| 180 | + | t.Parallel() | |||
| 171 | for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} { | 181 | for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} { | ||
| 172 | got := mergeRequestStateToPB(s) | 182 | got := mergeRequestStateToPB(s) | ||
| 173 | assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s) | 183 | assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s) | ||
| @@ -192,6 +202,7 @@ func TestMergeRequestEnumMappers(t *testing.T) { | |||||
| 192 | } | 202 | } | ||
| 193 | 203 | ||||
| 194 | func TestCommentID(t *testing.T) { | 204 | func TestCommentID(t *testing.T) { | ||
| 205 | + | t.Parallel() | |||
| 195 | assert.EqualValues(t, 7, commentID("7")) | 206 | assert.EqualValues(t, 7, commentID("7")) | ||
| 196 | for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} { | 207 | for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} { | ||
| 197 | assert.Zero(t, commentID(bad), "%q names no comment", bad) | 208 | assert.Zero(t, commentID(bad), "%q names no comment", bad) | ||
| @@ -199,13 +210,14 @@ func TestCommentID(t *testing.T) { | |||||
| 199 | } | 210 | } | ||
| 200 | 211 | ||||
| 201 | func TestRepoToPBAlwaysHasSettings(t *testing.T) { | 212 | func TestRepoToPBAlwaysHasSettings(t *testing.T) { | ||
| 213 | + | t.Parallel() | |||
| 202 | // A repository value without settings (never stored with them) reads as the defaults. | 214 | // A repository value without settings (never stored with them) reads as the defaults. | ||
| 203 | - | r := repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}}) | 215 | + | r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}}) |
| 204 | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes()) | 216 | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes()) | ||
| 205 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | 217 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | ||
| 206 | 218 | ||||
| 207 | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}} | 219 | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}} | ||
| 208 | - | r = repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}}) | 220 | + | r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}}) |
| 209 | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes()) | 221 | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes()) | ||
| 210 | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | 222 | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | ||
| 211 | } | 223 | } | ||
| @@ -218,6 +230,7 @@ func mustName(t *testing.T, s string) domain.RepoName { | |||||
| 218 | } | 230 | } | ||
| 219 | 231 | ||||
| 220 | func TestUpdateParamsSettingsPaths(t *testing.T) { | 232 | func TestUpdateParamsSettingsPaths(t *testing.T) { | ||
| 233 | + | t.Parallel() | |||
| 221 | msg := func(paths ...string) *pb.UpdateRepoRequest { | 234 | msg := func(paths ...string) *pb.UpdateRepoRequest { | ||
| 222 | return &pb.UpdateRepoRequest{ | 235 | return &pb.UpdateRepoRequest{ | ||
| 223 | UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}, | 236 | UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}, | ||
| @@ -255,6 +268,7 @@ func TestUpdateParamsSettingsPaths(t *testing.T) { | |||||
| 255 | } | 268 | } | ||
| 256 | 269 | ||||
| 257 | func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) { | 270 | func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) { | ||
| 271 | + | t.Parallel() | |||
| 258 | var v domain.Violations | 272 | var v domain.Violations | ||
| 259 | v.Add("title", "must be one line") | 273 | v.Add("title", "must be one line") | ||
| 260 | v.Add("source_branch", "does not exist") | 274 | v.Add("source_branch", "does not exist") | ||
| @@ -282,6 +296,7 @@ func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) { | |||||
| 282 | } | 296 | } | ||
| 283 | 297 | ||||
| 284 | func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | 298 | func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | ||
| 299 | + | t.Parallel() | |||
| 285 | repo := mustName(t, "proj") | 300 | repo := mustName(t, "proj") | ||
| 286 | title, err := domain.ParseMRTitle("Add x") | 301 | title, err := domain.ParseMRTitle("Add x") | ||
| 287 | require.NoError(t, err) | 302 | require.NoError(t, err) | ||
| @@ -297,7 +312,8 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | |||||
| 297 | "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }}, | 312 | "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }}, | ||
| 298 | } { | 313 | } { | ||
| 299 | t.Run(name, func(t *testing.T) { | 314 | t.Run(name, func(t *testing.T) { | ||
| 300 | - | got := watchEventToPB(app.WatchEvent{ID: "e1", Event: tc.ev}) | 315 | + | t.Parallel() |
| 316 | + | got := watchEventToPB(ports.WatchEvent{ID: "e1", Event: tc.ev}) | |||
| 301 | require.NotNil(t, got) | 317 | require.NotNil(t, got) | ||
| 302 | assert.Equal(t, "e1", got.GetId()) | 318 | assert.Equal(t, "e1", got.GetId()) | ||
| 303 | assert.Equal(t, at, got.GetTime().AsTime()) | 319 | assert.Equal(t, at, got.GetTime().AsTime()) | ||
| @@ -311,7 +327,7 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | |||||
| 311 | } | 327 | } | ||
| 312 | 328 | ||||
| 313 | c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at} | 329 | c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at} | ||
| 314 | - | got := watchEventToPB(app.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}}) | 330 | + | got := watchEventToPB(ports.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}}) |
| 315 | added := got.GetCommentAdded() | 331 | added := got.GetCommentAdded() | ||
| 316 | require.NotNil(t, added) | 332 | require.NotNil(t, added) | ||
| 317 | assert.Equal(t, "proj", added.GetRepo()) | 333 | assert.Equal(t, "proj", added.GetRepo()) | ||
M internal/adapters/rpc/errors.go +2 -13
| @@ -56,23 +56,12 @@ func codeOf(err error) (connect.Code, bool) { | |||||
| 56 | case errors.Is(err, domain.ErrResourceExhausted): | 56 | case errors.Is(err, domain.ErrResourceExhausted): | ||
| 57 | return connect.CodeResourceExhausted, true | 57 | return connect.CodeResourceExhausted, true | ||
| 58 | } | 58 | } | ||
| 59 | - | for _, invalid := range invalidErrors { | 59 | + | if domain.IsInvalid(err) { |
| 60 | - | if errors.Is(err, invalid) { | 60 | + | return connect.CodeInvalidArgument, true |
| 61 | - | return connect.CodeInvalidArgument, true | |||
| 62 | - | } | |||
| 63 | } | 61 | } | ||
| 64 | return 0, false | 62 | return 0, false | ||
| 65 | } | 63 | } | ||
| 66 | 64 | ||||
| 67 | - | // invalidErrors are the domain errors that mean the request was malformed. | |||
| 68 | - | var invalidErrors = []error{ | |||
| 69 | - | domain.ErrInvalid, domain.ErrInvalidName, domain.ErrInvalidVisibility, | |||
| 70 | - | domain.ErrInvalidScope, domain.ErrInvalidToken, | |||
| 71 | - | domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath, | |||
| 72 | - | domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind, | |||
| 73 | - | domain.ErrNotDirectory, domain.ErrNotFile, domain.ErrInvalidText, | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | // addFieldDetail attaches the fields a request was rejected for as a | 65 | // addFieldDetail attaches the fields a request was rejected for as a | ||
| 77 | // BadRequest detail, when err carries them. | 66 | // BadRequest detail, when err carries them. | ||
| 78 | func addFieldDetail(ce *connect.Error, err error) { | 67 | func addFieldDetail(ce *connect.Error, err error) { | ||
M internal/adapters/rpc/event.go +4 -4
| @@ -8,11 +8,11 @@ import ( | |||||
| 8 | "connectrpc.com/connect" | 8 | "connectrpc.com/connect" | ||
| 9 | 9 | ||||
| 10 | pb "bnema.dev/ermite-api/gen/ermite" | 10 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 11 | - | "bnema.dev/ermite/internal/app" | 11 | + | "bnema.dev/ermite/internal/ports" |
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // eventService implements ermiteconnect.EventServiceHandler. | 14 | // eventService implements ermiteconnect.EventServiceHandler. | ||
| 15 | - | type eventService struct{ events *app.Events } | 15 | + | type eventService struct{ events ports.EventService } |
| 16 | 16 | ||||
| 17 | // Watch streams events until the client goes away or the server stops. The | 17 | // Watch streams events until the client goes away or the server stops. The | ||
| 18 | // stream ends with success in both cases: the contract has no error for | 18 | // stream ends with success in both cases: the contract has no error for | ||
| @@ -20,7 +20,7 @@ type eventService struct{ events *app.Events } | |||||
| 20 | // the caller is revoked or expires, and a caller that has too many streams | 20 | // the caller is revoked or expires, and a caller that has too many streams | ||
| 21 | // open gets resource_exhausted. | 21 | // open gets resource_exhausted. | ||
| 22 | func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error { | 22 | func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error { | ||
| 23 | - | params := app.WatchParams{ | 23 | + | params := ports.WatchParams{ |
| 24 | Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(), | 24 | Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(), | ||
| 25 | Client: clientFromContext(ctx), | 25 | Client: clientFromContext(ctx), | ||
| 26 | // A client's call returns when the response begins: send the headers | 26 | // A client's call returns when the response begins: send the headers | ||
| @@ -28,7 +28,7 @@ func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchR | |||||
| 28 | // them for a nil message. A failure shows on the first real send. | 28 | // them for a nil message. A failure shows on the first real send. | ||
| 29 | Ready: func() { _ = stream.Send(nil) }, | 29 | Ready: func() { _ = stream.Send(nil) }, | ||
| 30 | } | 30 | } | ||
| 31 | - | err := s.events.Watch(ctx, caller(ctx), params, func(we app.WatchEvent) error { | 31 | + | err := s.events.Watch(ctx, caller(ctx), params, func(we ports.WatchEvent) error { |
| 32 | ev := watchEventToPB(we) | 32 | ev := watchEventToPB(we) | ||
| 33 | if ev == nil { | 33 | if ev == nil { | ||
| 34 | return nil | 34 | return nil | ||
M internal/adapters/rpc/git.go +4 -4
| @@ -11,15 +11,15 @@ import ( | |||||
| 11 | "connectrpc.com/connect" | 11 | "connectrpc.com/connect" | ||
| 12 | 12 | ||||
| 13 | pb "bnema.dev/ermite-api/gen/ermite" | 13 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 14 | - | "bnema.dev/ermite/internal/app" | |||
| 15 | "bnema.dev/ermite/internal/domain" | 14 | "bnema.dev/ermite/internal/domain" | ||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | // BlobChunkSize is the largest chunk of a GetBlob stream. | 18 | // BlobChunkSize is the largest chunk of a GetBlob stream. | ||
| 19 | const BlobChunkSize = 64 << 10 | 19 | const BlobChunkSize = 64 << 10 | ||
| 20 | 20 | ||||
| 21 | // gitService implements ermiteconnect.GitServiceHandler. | 21 | // gitService implements ermiteconnect.GitServiceHandler. | ||
| 22 | - | type gitService struct{ browse *app.Browse } | 22 | + | type gitService struct{ browse ports.BrowseService } |
| 23 | 23 | ||||
| 24 | // target is the repository and revision most requests start with. | 24 | // target is the repository and revision most requests start with. | ||
| 25 | func target(repo, revision string) (domain.RepoName, domain.Revision, error) { | 25 | func target(repo, revision string) (domain.RepoName, domain.Revision, error) { | ||
| @@ -39,7 +39,7 @@ func (s *gitService) ListRefs(ctx context.Context, req *connect.Request[pb.ListR | |||||
| 39 | if err != nil { | 39 | if err != nil { | ||
| 40 | return nil, connectError(ctx, err) | 40 | return nil, connectError(ctx, err) | ||
| 41 | } | 41 | } | ||
| 42 | - | params := app.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()} | 42 | + | params := ports.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()} |
| 43 | for _, k := range req.Msg.GetKinds() { | 43 | for _, k := range req.Msg.GetKinds() { | ||
| 44 | kind, err := refKindFromPB(k) | 44 | kind, err := refKindFromPB(k) | ||
| 45 | if err != nil { | 45 | if err != nil { | ||
| @@ -162,7 +162,7 @@ func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.Li | |||||
| 162 | if err != nil { | 162 | if err != nil { | ||
| 163 | return nil, connectError(ctx, err) | 163 | return nil, connectError(ctx, err) | ||
| 164 | } | 164 | } | ||
| 165 | - | page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{ | 165 | + | page, err := s.browse.ListCommits(ctx, caller(ctx), name, ports.ListCommitsParams{ |
| 166 | Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | 166 | Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | ||
| 167 | }) | 167 | }) | ||
| 168 | if err != nil { | 168 | if err != nil { | ||
A internal/adapters/rpc/git_test.go +225 -0
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestGitServiceGetTree(t *testing.T) { | |||
| 21 | + | t.Parallel() | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | req *pb.GetTreeRequest | |||
| 25 | + | usecase error | |||
| 26 | + | wantCode connect.Code | |||
| 27 | + | wantCall bool | |||
| 28 | + | }{ | |||
| 29 | + | {name: "ok", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "src"}, wantCall: true}, | |||
| 30 | + | {name: "invalid repo", req: &pb.GetTreeRequest{Repo: "../x", Revision: "main"}, wantCode: connect.CodeInvalidArgument}, | |||
| 31 | + | {name: "invalid path", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "a/../b"}, wantCode: connect.CodeInvalidArgument}, | |||
| 32 | + | {name: "repo not visible", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 33 | + | {name: "path is a file", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "f"}, usecase: domain.ErrNotDirectory, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |||
| 34 | + | } | |||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | t.Parallel() | |||
| 38 | + | browse := mocks.NewBrowseService(t) | |||
| 39 | + | if tt.wantCall { | |||
| 40 | + | browse.EXPECT().GetTree(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything, false). | |||
| 41 | + | Return(domain.Tree{}, tt.usecase).Once() | |||
| 42 | + | } | |||
| 43 | + | resp, err := (&gitService{browse}).GetTree(ownerCtx(), connect.NewRequest(tt.req)) | |||
| 44 | + | if tt.wantCode != 0 { | |||
| 45 | + | requireCode(t, tt.wantCode, err) | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.Empty(t, resp.Msg.GetEntries()) | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 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 | + | ||||
| 104 | + | func TestGitServiceListRefs(t *testing.T) { | |||
| 105 | + | t.Parallel() | |||
| 106 | + | tests := []struct { | |||
| 107 | + | name string | |||
| 108 | + | req *pb.ListRefsRequest | |||
| 109 | + | usecase error | |||
| 110 | + | wantCode connect.Code | |||
| 111 | + | wantCall bool | |||
| 112 | + | }{ | |||
| 113 | + | {name: "ok", req: &pb.ListRefsRequest{Repo: "demo", PageSize: 10}, wantCall: true}, | |||
| 114 | + | {name: "unknown kind", req: &pb.ListRefsRequest{Repo: "demo", Kinds: []pb.RefKind{pb.RefKind(99)}}, wantCode: connect.CodeInvalidArgument}, | |||
| 115 | + | {name: "invalid repo", req: &pb.ListRefsRequest{Repo: ""}, wantCode: connect.CodeInvalidArgument}, | |||
| 116 | + | {name: "bad page token", req: &pb.ListRefsRequest{Repo: "demo", PageToken: "zz"}, usecase: domain.ErrInvalidPageToken, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |||
| 117 | + | {name: "not found", req: &pb.ListRefsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 118 | + | } | |||
| 119 | + | for _, tt := range tests { | |||
| 120 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 121 | + | t.Parallel() | |||
| 122 | + | browse := mocks.NewBrowseService(t) | |||
| 123 | + | if tt.wantCall { | |||
| 124 | + | browse.EXPECT().ListRefs(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything). | |||
| 125 | + | Return(ports.RefPage{NextPageToken: "n"}, tt.usecase).Once() | |||
| 126 | + | } | |||
| 127 | + | resp, err := (&gitService{browse}).ListRefs(ownerCtx(), connect.NewRequest(tt.req)) | |||
| 128 | + | if tt.wantCode != 0 { | |||
| 129 | + | requireCode(t, tt.wantCode, err) | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | assert.Equal(t, "n", resp.Msg.GetNextPageToken()) | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestGitServiceGetCommitAndResolveRevision(t *testing.T) { | |||
| 139 | + | t.Parallel() | |||
| 140 | + | invalid := []struct{ name, repo, rev string }{ | |||
| 141 | + | {"invalid repo", "../x", "main"}, | |||
| 142 | + | {"invalid revision", "demo", "a b"}, | |||
| 143 | + | } | |||
| 144 | + | for _, tt := range invalid { | |||
| 145 | + | t.Run("GetCommit "+tt.name, func(t *testing.T) { | |||
| 146 | + | t.Parallel() | |||
| 147 | + | _, err := (&gitService{mocks.NewBrowseService(t)}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: tt.repo, Revision: tt.rev})) | |||
| 148 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 149 | + | }) | |||
| 150 | + | t.Run("ResolveRevision "+tt.name, func(t *testing.T) { | |||
| 151 | + | t.Parallel() | |||
| 152 | + | _, err := (&gitService{mocks.NewBrowseService(t)}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: tt.repo, Revision: tt.rev})) | |||
| 153 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 154 | + | }) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | t.Run("GetCommit not found", func(t *testing.T) { | |||
| 158 | + | t.Parallel() | |||
| 159 | + | browse := mocks.NewBrowseService(t) | |||
| 160 | + | browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once() | |||
| 161 | + | _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"})) | |||
| 162 | + | requireCode(t, connect.CodeNotFound, err) | |||
| 163 | + | }) | |||
| 164 | + | t.Run("ResolveRevision ok", func(t *testing.T) { | |||
| 165 | + | t.Parallel() | |||
| 166 | + | browse := mocks.NewBrowseService(t) | |||
| 167 | + | browse.EXPECT().ResolveRevision(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything). | |||
| 168 | + | Return(domain.ResolvedRevision{Ref: "refs/heads/main"}, nil).Once() | |||
| 169 | + | resp, err := (&gitService{browse}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: "demo", Revision: "main"})) | |||
| 170 | + | require.NoError(t, err) | |||
| 171 | + | assert.Equal(t, "refs/heads/main", resp.Msg.GetRef()) | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestGitServiceListCommits(t *testing.T) { | |||
| 176 | + | t.Parallel() | |||
| 177 | + | tests := []struct { | |||
| 178 | + | name string | |||
| 179 | + | req *pb.ListCommitsRequest | |||
| 180 | + | usecase error | |||
| 181 | + | wantCode connect.Code | |||
| 182 | + | wantCall bool | |||
| 183 | + | }{ | |||
| 184 | + | {name: "ok", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main"}, wantCall: true}, | |||
| 185 | + | {name: "invalid path", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", Path: "/abs/../x"}, wantCode: connect.CodeInvalidArgument}, | |||
| 186 | + | {name: "bad page size", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", PageSize: -1}, usecase: domain.ErrInvalidPageSize, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |||
| 187 | + | {name: "not found", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 188 | + | } | |||
| 189 | + | for _, tt := range tests { | |||
| 190 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 191 | + | t.Parallel() | |||
| 192 | + | browse := mocks.NewBrowseService(t) | |||
| 193 | + | if tt.wantCall { | |||
| 194 | + | browse.EXPECT().ListCommits(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything). | |||
| 195 | + | Return(ports.HistoryPage{}, tt.usecase).Once() | |||
| 196 | + | } | |||
| 197 | + | _, err := (&gitService{browse}).ListCommits(ownerCtx(), connect.NewRequest(tt.req)) | |||
| 198 | + | if tt.wantCode != 0 { | |||
| 199 | + | requireCode(t, tt.wantCode, err) | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | require.NoError(t, err) | |||
| 203 | + | }) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // GetBlob validates before it opens anything or touches the stream. | |||
| 208 | + | func TestGitServiceGetBlobRejectsBadInput(t *testing.T) { | |||
| 209 | + | t.Parallel() | |||
| 210 | + | tests := []struct { | |||
| 211 | + | name string | |||
| 212 | + | req *pb.GetBlobRequest | |||
| 213 | + | }{ | |||
| 214 | + | {"invalid repo", &pb.GetBlobRequest{Repo: "../x", Revision: "main", Path: "f"}}, | |||
| 215 | + | {"missing path", &pb.GetBlobRequest{Repo: "demo", Revision: "main"}}, | |||
| 216 | + | {"invalid path", &pb.GetBlobRequest{Repo: "demo", Revision: "main", Path: "a/../b"}}, | |||
| 217 | + | } | |||
| 218 | + | for _, tt := range tests { | |||
| 219 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 220 | + | t.Parallel() | |||
| 221 | + | err := (&gitService{mocks.NewBrowseService(t)}).GetBlob(ownerCtx(), connect.NewRequest(tt.req), nil) | |||
| 222 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 223 | + | }) | |||
| 224 | + | } | |||
| 225 | + | } | |||
D internal/adapters/rpc/inprocess.go +0 -131
| @@ -1,131 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package rpc | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "io" | |||
| 8 | - | "net/http" | |||
| 9 | - | "sync" | |||
| 10 | - | ||||
| 11 | - | "bnema.dev/ermite/internal/domain" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // inProcessHost is the host of the URLs of in-process calls. The name is | |||
| 15 | - | // never resolved. | |||
| 16 | - | const inProcessHost = "ermite.invalid" | |||
| 17 | - | ||||
| 18 | - | // InProcessURL is the base URL to give to the generated Connect clients that | |||
| 19 | - | // use InProcessClient. | |||
| 20 | - | const InProcessURL = "http://" + inProcessHost | |||
| 21 | - | ||||
| 22 | - | // InProcessClient returns an HTTP client whose requests are served by the | |||
| 23 | - | // handlers of the API in this process: no socket, no network hop. Build the | |||
| 24 | - | // generated Connect clients on it: | |||
| 25 | - | // | |||
| 26 | - | // hc := api.InProcessClient() | |||
| 27 | - | // repos := ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL) | |||
| 28 | - | // | |||
| 29 | - | // The caller is the principal found in the context of each call | |||
| 30 | - | // (domain.ContextWithPrincipal), anonymous when there is none; the | |||
| 31 | - | // Authorization header is ignored. This is the identity of the web (P5): | |||
| 32 | - | // anonymous for visitors, the owner for a session. Only code in this process | |||
| 33 | - | // can put the marker that makes the interceptor trust it. | |||
| 34 | - | // Unary and server-streaming calls work, with all the codecs; the request | |||
| 35 | - | // log and the error mapping are those of remote calls. | |||
| 36 | - | func (a *API) InProcessClient() *http.Client { | |||
| 37 | - | return &http.Client{Transport: inProcessTransport{h: a.handler}} | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | type inProcessTransport struct{ h http.Handler } | |||
| 41 | - | ||||
| 42 | - | // inProcessKey marks the context of a call made through InProcessClient. It is | |||
| 43 | - | // unexported, so that nothing outside this package, and nothing that comes | |||
| 44 | - | // from the network, can make the interceptor trust a principal. | |||
| 45 | - | type inProcessKey struct{} | |||
| 46 | - | ||||
| 47 | - | func inProcess(ctx context.Context) bool { | |||
| 48 | - | _, ok := ctx.Value(inProcessKey{}).(struct{}) | |||
| 49 | - | return ok | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | // RoundTrip serves req with the handler in a goroutine, and returns once the | |||
| 53 | - | // handler wrote its status. The body of the response is a pipe the handler | |||
| 54 | - | // writes to, so that streams are delivered as they are sent. | |||
| 55 | - | func (t inProcessTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 56 | - | ctx := req.Context() | |||
| 57 | - | p, ok := domain.PrincipalFromContext(ctx) | |||
| 58 | - | if !ok { | |||
| 59 | - | p = domain.Anonymous() | |||
| 60 | - | } | |||
| 61 | - | sreq := req.Clone(context.WithValue(domain.ContextWithPrincipal(ctx, p), inProcessKey{}, struct{}{})) | |||
| 62 | - | sreq.RemoteAddr = "in-process" | |||
| 63 | - | sreq.RequestURI = req.URL.RequestURI() | |||
| 64 | - | if sreq.Body == nil { | |||
| 65 | - | sreq.Body = http.NoBody | |||
| 66 | - | } | |||
| 67 | - | sreq.Header.Del("Authorization") | |||
| 68 | - | ||||
| 69 | - | pr, pw := io.Pipe() | |||
| 70 | - | w := &pipeResponse{header: http.Header{}, pw: pw, ready: make(chan struct{})} | |||
| 71 | - | go func() { | |||
| 72 | - | defer func() { | |||
| 73 | - | if r := recover(); r != nil { | |||
| 74 | - | w.writeHeader(http.StatusInternalServerError) | |||
| 75 | - | _ = pw.CloseWithError(io.ErrUnexpectedEOF) | |||
| 76 | - | } | |||
| 77 | - | }() | |||
| 78 | - | t.h.ServeHTTP(w, sreq) | |||
| 79 | - | w.writeHeader(http.StatusOK) // a handler that wrote nothing | |||
| 80 | - | _ = pw.Close() | |||
| 81 | - | }() | |||
| 82 | - | ||||
| 83 | - | select { | |||
| 84 | - | case <-w.ready: | |||
| 85 | - | case <-ctx.Done(): | |||
| 86 | - | _ = pr.CloseWithError(ctx.Err()) | |||
| 87 | - | return nil, ctx.Err() | |||
| 88 | - | } | |||
| 89 | - | return &http.Response{ | |||
| 90 | - | Status: http.StatusText(w.status), | |||
| 91 | - | StatusCode: w.status, | |||
| 92 | - | Proto: "HTTP/1.1", | |||
| 93 | - | ProtoMajor: 1, | |||
| 94 | - | ProtoMinor: 1, | |||
| 95 | - | Header: w.sent, | |||
| 96 | - | Body: pr, | |||
| 97 | - | ContentLength: -1, | |||
| 98 | - | Request: req, | |||
| 99 | - | }, nil | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | // pipeResponse is the http.ResponseWriter of an in-process call. | |||
| 103 | - | type pipeResponse struct { | |||
| 104 | - | header http.Header | |||
| 105 | - | pw *io.PipeWriter | |||
| 106 | - | ||||
| 107 | - | once sync.Once | |||
| 108 | - | ready chan struct{} | |||
| 109 | - | status int | |||
| 110 | - | sent http.Header // header as of WriteHeader | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | func (w *pipeResponse) Header() http.Header { return w.header } | |||
| 114 | - | ||||
| 115 | - | func (w *pipeResponse) WriteHeader(code int) { w.writeHeader(code) } | |||
| 116 | - | ||||
| 117 | - | func (w *pipeResponse) writeHeader(code int) { | |||
| 118 | - | w.once.Do(func() { | |||
| 119 | - | w.status, w.sent = code, w.header.Clone() | |||
| 120 | - | close(w.ready) | |||
| 121 | - | }) | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func (w *pipeResponse) Write(b []byte) (int, error) { | |||
| 125 | - | w.writeHeader(http.StatusOK) | |||
| 126 | - | return w.pw.Write(b) | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | // Flush implements http.Flusher, which streaming handlers require: the pipe | |||
| 130 | - | // delivers every write at once, there is nothing to flush but the header. | |||
| 131 | - | func (w *pipeResponse) Flush() { w.writeHeader(http.StatusOK) } | |||
M internal/adapters/rpc/interceptor.go +7 -24
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 18 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | ||
| 19 | "bnema.dev/ermite/internal/clientlimit" | 19 | "bnema.dev/ermite/internal/clientlimit" | ||
| 20 | "bnema.dev/ermite/internal/domain" | 20 | "bnema.dev/ermite/internal/domain" | ||
| 21 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | var errInternal = errors.New("internal error") | 24 | var errInternal = errors.New("internal error") | ||
| @@ -30,15 +31,12 @@ var errInternal = errors.New("internal error") | |||||
| 30 | // limiter; a client that used up its failures gets resource_exhausted (429 on | 31 | // limiter; a client that used up its failures gets resource_exhausted (429 on | ||
| 31 | // the Connect protocol) whatever it presents, like on the Git routes. | 32 | // the Connect protocol) whatever it presents, like on the Git routes. | ||
| 32 | // | 33 | // | ||
| 33 | - | // A call made through InProcessClient is trusted with the principal its | 34 | + | // A principal in the context of a network request, say one put there by a |
| 34 | - | // context carries: that is how the web calls the handlers in process. What | 35 | + | // server's base context, is ignored and the request is authenticated from its |
| 35 | - | // marks it is a value only the in-process transport can put in the context | 36 | + | // headers. |
| 36 | - | // (see inProcessKey); a principal in the context of a network request, say one | |||
| 37 | - | // put there by a server's base context, is ignored and the request is | |||
| 38 | - | // authenticated from its headers. | |||
| 39 | type interceptor struct { | 37 | type interceptor struct { | ||
| 40 | - | auth Authenticator | 38 | + | auth ports.AuthService |
| 41 | - | limiter *clientlimit.Limiter | 39 | + | limiter ports.FailureLimiter |
| 42 | trusted []netip.Prefix | 40 | trusted []netip.Prefix | ||
| 43 | } | 41 | } | ||
| 44 | 42 | ||||
| @@ -48,11 +46,6 @@ func newInterceptor(cfg Config) *interceptor { | |||||
| 48 | 46 | ||||
| 49 | // authenticate returns the context to run the call in. | 47 | // authenticate returns the context to run the call in. | ||
| 50 | func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) { | 48 | func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) { | ||
| 51 | - | if inProcess(ctx) { | |||
| 52 | - | if _, ok := domain.PrincipalFromContext(ctx); ok { | |||
| 53 | - | return ctx, nil | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | // Whatever the context carried, the caller is anonymous until proven | 49 | // Whatever the context carried, the caller is anonymous until proven | ||
| 57 | // otherwise. | 50 | // otherwise. | ||
| 58 | ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous()) | 51 | ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous()) | ||
| @@ -90,18 +83,8 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri | |||||
| 90 | 83 | ||||
| 91 | type clientKey struct{} | 84 | type clientKey struct{} | ||
| 92 | 85 | ||||
| 93 | - | // ContextWithClient attributes the in-process calls made with ctx to the | |||
| 94 | - | // client at addr (clientlimit.ClientAddr of the browser's request), so that | |||
| 95 | - | // the per-client limits of the API, such as the event streams one, apply to | |||
| 96 | - | // each visitor of the web rather than to the web as a whole. A network call | |||
| 97 | - | // is always attributed to its own peer: the interceptor overwrites it. | |||
| 98 | - | func ContextWithClient(ctx context.Context, addr string) context.Context { | |||
| 99 | - | return context.WithValue(ctx, clientKey{}, clientlimit.Key(addr)) | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | // clientFromContext returns the normalized address of the client of a call: | 86 | // clientFromContext returns the normalized address of the client of a call: | ||
| 103 | - | // its peer for a network call, the one given to ContextWithClient for an | 87 | + | // its peer, empty when there is none. |
| 104 | - | // in-process call, empty when there is none. | |||
| 105 | func clientFromContext(ctx context.Context) string { | 88 | func clientFromContext(ctx context.Context) string { | ||
| 106 | c, _ := ctx.Value(clientKey{}).(string) | 89 | c, _ := ctx.Value(clientKey{}).(string) | ||
| 107 | return c | 90 | return c | ||
A internal/adapters/rpc/interceptor_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "net/http" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "connectrpc.com/connect" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | const peer = "203.0.113.7:4242" | |||
| 23 | + | ||||
| 24 | + | func bearerHeader(v string) http.Header { | |||
| 25 | + | h := http.Header{} | |||
| 26 | + | if v != "" { | |||
| 27 | + | h.Set("Authorization", v) | |||
| 28 | + | } | |||
| 29 | + | return h | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestInterceptorAuthenticate(t *testing.T) { | |||
| 33 | + | t.Parallel() | |||
| 34 | + | const procedure = ermiteconnect.RepoServiceListReposProcedure | |||
| 35 | + | tests := []struct { | |||
| 36 | + | name string | |||
| 37 | + | procedure string | |||
| 38 | + | header http.Header | |||
| 39 | + | blocked bool | |||
| 40 | + | authErr error | |||
| 41 | + | wantCode connect.Code // 0: success | |||
| 42 | + | wantAuth bool | |||
| 43 | + | wantFail bool | |||
| 44 | + | wantLimit bool // the limiter is asked at all | |||
| 45 | + | wantRetry string | |||
| 46 | + | wantOwner bool | |||
| 47 | + | }{ | |||
| 48 | + | {name: "no header is anonymous", procedure: procedure, header: bearerHeader(""), wantLimit: true}, | |||
| 49 | + | {name: "valid token", procedure: procedure, header: bearerHeader("Bearer good"), wantLimit: true, wantAuth: true, wantOwner: true}, | |||
| 50 | + | { | |||
| 51 | + | name: "invalid token counts as a failure", procedure: procedure, header: bearerHeader("Bearer bad"), | |||
| 52 | + | authErr: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantLimit: true, wantAuth: true, wantFail: true, | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | name: "non-bearer scheme is a failure", procedure: procedure, header: bearerHeader("Basic abc"), | |||
| 56 | + | authErr: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantLimit: true, wantAuth: true, wantFail: true, | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "authenticator outage is not a failure of the client", procedure: procedure, header: bearerHeader("Bearer good"), | |||
| 60 | + | authErr: errors.New("store down"), wantCode: connect.CodeInternal, wantLimit: true, wantAuth: true, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "blocked client is refused with Retry-After", procedure: procedure, header: bearerHeader("Bearer good"), | |||
| 64 | + | blocked: true, wantCode: connect.CodeResourceExhausted, wantLimit: true, wantRetry: "30", | |||
| 65 | + | }, | |||
| 66 | + | {name: "version needs no authentication", procedure: ermiteconnect.ServerServiceGetVersionProcedure, header: bearerHeader("Bearer expired")}, | |||
| 67 | + | {name: "signing key needs no authentication", procedure: ermiteconnect.ServerServiceGetSigningKeyProcedure, header: bearerHeader("Bearer expired")}, | |||
| 68 | + | } | |||
| 69 | + | for _, tt := range tests { | |||
| 70 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 71 | + | t.Parallel() | |||
| 72 | + | auth := mocks.NewAuthService(t) | |||
| 73 | + | limiter := mocks.NewFailureLimiter(t) | |||
| 74 | + | if tt.wantLimit { | |||
| 75 | + | wait := time.Duration(0) | |||
| 76 | + | if tt.blocked { | |||
| 77 | + | wait = 29*time.Second + 500*time.Millisecond | |||
| 78 | + | } | |||
| 79 | + | limiter.EXPECT().Blocked(mock.Anything, "203.0.113.7").Return(tt.blocked, wait).Once() | |||
| 80 | + | } | |||
| 81 | + | if tt.wantAuth { | |||
| 82 | + | p := domain.Principal{} | |||
| 83 | + | if tt.wantOwner { | |||
| 84 | + | p = domain.Owner() | |||
| 85 | + | } | |||
| 86 | + | auth.EXPECT().Authenticate(mock.Anything, mock.Anything).Return(p, tt.authErr).Once() | |||
| 87 | + | } | |||
| 88 | + | if tt.wantFail { | |||
| 89 | + | limiter.EXPECT().Fail(mock.Anything, "203.0.113.7").Once() | |||
| 90 | + | } | |||
| 91 | + | i := newInterceptor(Config{Auth: auth, Limiter: limiter}) | |||
| 92 | + | ctx, err := i.authenticate(context.Background(), tt.procedure, peer, tt.header) | |||
| 93 | + | if tt.wantCode != 0 { | |||
| 94 | + | requireCode(t, tt.wantCode, err) | |||
| 95 | + | if tt.wantRetry != "" { | |||
| 96 | + | var ce *connect.Error | |||
| 97 | + | require.ErrorAs(t, err, &ce) | |||
| 98 | + | assert.Equal(t, tt.wantRetry, ce.Meta().Get("Retry-After")) | |||
| 99 | + | } | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | p, ok := domain.PrincipalFromContext(ctx) | |||
| 104 | + | require.True(t, ok) | |||
| 105 | + | if tt.wantOwner { | |||
| 106 | + | assert.Equal(t, domain.Owner(), p) | |||
| 107 | + | } else { | |||
| 108 | + | assert.Equal(t, domain.Anonymous(), p) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestInterceptorIgnoresForwardedForFromUntrustedPeer(t *testing.T) { | |||
| 115 | + | t.Parallel() | |||
| 116 | + | limiter := mocks.NewFailureLimiter(t) | |||
| 117 | + | limiter.EXPECT().Blocked(mock.Anything, "203.0.113.7").Return(false, time.Duration(0)).Once() | |||
| 118 | + | h := bearerHeader("") | |||
| 119 | + | h.Set("X-Forwarded-For", "198.51.100.1") | |||
| 120 | + | i := newInterceptor(Config{Auth: mocks.NewAuthService(t), Limiter: limiter}) | |||
| 121 | + | _, err := i.authenticate(context.Background(), ermiteconnect.RepoServiceListReposProcedure, peer, h) | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | } | |||
M internal/adapters/rpc/mergerequest.go +6 -6
| @@ -10,12 +10,12 @@ import ( | |||||
| 10 | "connectrpc.com/connect" | 10 | "connectrpc.com/connect" | ||
| 11 | 11 | ||||
| 12 | pb "bnema.dev/ermite-api/gen/ermite" | 12 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 13 | - | "bnema.dev/ermite/internal/app" | |||
| 14 | "bnema.dev/ermite/internal/domain" | 13 | "bnema.dev/ermite/internal/domain" | ||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler. | 17 | // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler. | ||
| 18 | - | type mergeRequestService struct{ mrs *app.MergeRequests } | 18 | + | type mergeRequestService struct{ mrs ports.MergeRequestService } |
| 19 | 19 | ||||
| 20 | func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState { | 20 | func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState { | ||
| 21 | switch s { | 21 | switch s { | ||
| @@ -129,7 +129,7 @@ func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *conne | |||||
| 129 | if err != nil { | 129 | if err != nil { | ||
| 130 | return nil, connectError(ctx, err) | 130 | return nil, connectError(ctx, err) | ||
| 131 | } | 131 | } | ||
| 132 | - | mr, err := s.mrs.Create(ctx, caller(ctx), name, app.CreateMergeRequestParams{ | 132 | + | mr, err := s.mrs.Create(ctx, caller(ctx), name, ports.CreateMergeRequestParams{ |
| 133 | Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(), | 133 | Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(), | ||
| 134 | Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(), | 134 | Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(), | ||
| 135 | }) | 135 | }) | ||
| @@ -148,7 +148,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec | |||||
| 148 | if err != nil { | 148 | if err != nil { | ||
| 149 | return nil, connectError(ctx, err) | 149 | return nil, connectError(ctx, err) | ||
| 150 | } | 150 | } | ||
| 151 | - | page, err := s.mrs.List(ctx, caller(ctx), name, app.ListMergeRequestsParams{ | 151 | + | page, err := s.mrs.List(ctx, caller(ctx), name, ports.ListMergeRequestsParams{ |
| 152 | State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | 152 | State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | ||
| 153 | }) | 153 | }) | ||
| 154 | if err != nil { | 154 | if err != nil { | ||
| @@ -197,7 +197,7 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque | |||||
| 197 | if err != nil { | 197 | if err != nil { | ||
| 198 | return nil, connectError(ctx, err) | 198 | return nil, connectError(ctx, err) | ||
| 199 | } | 199 | } | ||
| 200 | - | c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), app.AddCommentParams{ | 200 | + | c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{ |
| 201 | Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(), | 201 | Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(), | ||
| 202 | }) | 202 | }) | ||
| 203 | if err != nil { | 203 | if err != nil { | ||
| @@ -245,7 +245,7 @@ func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connec | |||||
| 245 | if err != nil { | 245 | if err != nil { | ||
| 246 | return nil, connectError(ctx, err) | 246 | return nil, connectError(ctx, err) | ||
| 247 | } | 247 | } | ||
| 248 | - | res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{ | 248 | + | res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{ |
| 249 | Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(), | 249 | Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(), | ||
| 250 | }) | 250 | }) | ||
| 251 | if err != nil { | 251 | if err != nil { | ||
A internal/adapters/rpc/mergerequest_test.go +227 -0
| @@ -0,0 +1,227 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "connectrpc.com/connect" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestMergeRequestServiceCreate(t *testing.T) { | |||
| 20 | + | t.Parallel() | |||
| 21 | + | tests := []struct { | |||
| 22 | + | name string | |||
| 23 | + | repo string | |||
| 24 | + | usecase error | |||
| 25 | + | wantCode connect.Code | |||
| 26 | + | wantCall bool | |||
| 27 | + | }{ | |||
| 28 | + | {name: "ok", repo: "demo", wantCall: true}, | |||
| 29 | + | {name: "invalid repo", repo: "../x", wantCode: connect.CodeInvalidArgument}, | |||
| 30 | + | {name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 31 | + | {name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |||
| 32 | + | {name: "already open", repo: "demo", usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true}, | |||
| 33 | + | {name: "invalid fields", repo: "demo", usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |||
| 34 | + | } | |||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | t.Parallel() | |||
| 38 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 39 | + | if tt.wantCall { | |||
| 40 | + | mrs.EXPECT().Create(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.CreateMergeRequestParams{ | |||
| 41 | + | Title: "t", Description: "d", Source: "feat", Target: "main", | |||
| 42 | + | }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once() | |||
| 43 | + | } | |||
| 44 | + | resp, err := (&mergeRequestService{mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{ | |||
| 45 | + | Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main", | |||
| 46 | + | })) | |||
| 47 | + | if tt.wantCode != 0 { | |||
| 48 | + | requireCode(t, tt.wantCode, err) | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber()) | |||
| 53 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState()) | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) { | |||
| 59 | + | t.Parallel() | |||
| 60 | + | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(), | |||
| 61 | + | connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)})) | |||
| 62 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestMergeRequestServiceListFiltersByState(t *testing.T) { | |||
| 66 | + | t.Parallel() | |||
| 67 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 68 | + | mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{ | |||
| 69 | + | State: domain.MRStateMerged, PageSize: 3, PageToken: "tok", | |||
| 70 | + | }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once() | |||
| 71 | + | resp, err := (&mergeRequestService{mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{ | |||
| 72 | + | Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok", | |||
| 73 | + | })) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | assert.Len(t, resp.Msg.GetMergeRequests(), 1) | |||
| 76 | + | assert.Equal(t, "n", resp.Msg.GetNextPageToken()) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestMergeRequestServiceAddComment(t *testing.T) { | |||
| 80 | + | t.Parallel() | |||
| 81 | + | tests := []struct { | |||
| 82 | + | name string | |||
| 83 | + | side pb.DiffSide | |||
| 84 | + | usecase error | |||
| 85 | + | wantCode connect.Code | |||
| 86 | + | wantCall bool | |||
| 87 | + | }{ | |||
| 88 | + | {name: "ok", side: pb.DiffSide_DIFF_SIDE_NEW, wantCall: true}, | |||
| 89 | + | {name: "unknown side", side: pb.DiffSide(99), wantCode: connect.CodeInvalidArgument}, | |||
| 90 | + | {name: "merge request not open", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true}, | |||
| 91 | + | {name: "concurrent update", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true}, | |||
| 92 | + | {name: "not found", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 93 | + | } | |||
| 94 | + | for _, tt := range tests { | |||
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 96 | + | t.Parallel() | |||
| 97 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 98 | + | if tt.wantCall { | |||
| 99 | + | mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything). | |||
| 100 | + | Return(domain.Comment{ID: 5}, tt.usecase).Once() | |||
| 101 | + | } | |||
| 102 | + | resp, err := (&mergeRequestService{mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{ | |||
| 103 | + | Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi", | |||
| 104 | + | })) | |||
| 105 | + | if tt.wantCode != 0 { | |||
| 106 | + | requireCode(t, tt.wantCode, err) | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | assert.Equal(t, "5", resp.Msg.GetComment().GetId()) | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) { | |||
| 116 | + | t.Parallel() | |||
| 117 | + | for _, id := range []string{"", "abc", "-1", "0", "007", "99999999999999999999"} { | |||
| 118 | + | t.Run(id, func(t *testing.T) { | |||
| 119 | + | t.Parallel() | |||
| 120 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 121 | + | mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true). | |||
| 122 | + | Return(domain.Comment{}, domain.ErrNotFound).Once() | |||
| 123 | + | _, err := (&mergeRequestService{mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{ | |||
| 124 | + | Repo: "demo", Number: 1, CommentId: id, Resolved: true, | |||
| 125 | + | })) | |||
| 126 | + | requireCode(t, connect.CodeNotFound, err) | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestMergeRequestServiceMerge(t *testing.T) { | |||
| 132 | + | t.Parallel() | |||
| 133 | + | const head = "0123456789abcdef0123456789abcdef01234567" | |||
| 134 | + | tests := []struct { | |||
| 135 | + | name string | |||
| 136 | + | req *pb.MergeMergeRequestRequest | |||
| 137 | + | usecase error | |||
| 138 | + | wantCode connect.Code | |||
| 139 | + | wantCall bool | |||
| 140 | + | }{ | |||
| 141 | + | {name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true}, | |||
| 142 | + | {name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument}, | |||
| 143 | + | {name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument}, | |||
| 144 | + | {name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true}, | |||
| 145 | + | {name: "checks failing", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true}, | |||
| 146 | + | {name: "forbidden", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |||
| 147 | + | } | |||
| 148 | + | for _, tt := range tests { | |||
| 149 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 150 | + | t.Parallel() | |||
| 151 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 152 | + | if tt.wantCall { | |||
| 153 | + | mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool { | |||
| 154 | + | return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero() | |||
| 155 | + | })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once() | |||
| 156 | + | } | |||
| 157 | + | resp, err := (&mergeRequestService{mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req)) | |||
| 158 | + | if tt.wantCode != 0 { | |||
| 159 | + | requireCode(t, tt.wantCode, err) | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | assert.True(t, resp.Msg.GetSourceBranchDeleted()) | |||
| 164 | + | }) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) { | |||
| 169 | + | t.Parallel() | |||
| 170 | + | t.Run("close not found", func(t *testing.T) { | |||
| 171 | + | t.Parallel() | |||
| 172 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 173 | + | mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once() | |||
| 174 | + | _, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9})) | |||
| 175 | + | requireCode(t, connect.CodeNotFound, err) | |||
| 176 | + | }) | |||
| 177 | + | t.Run("close ok", func(t *testing.T) { | |||
| 178 | + | t.Parallel() | |||
| 179 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 180 | + | mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once() | |||
| 181 | + | resp, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9})) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState()) | |||
| 184 | + | }) | |||
| 185 | + | t.Run("mark read rejects a short head", func(t *testing.T) { | |||
| 186 | + | t.Parallel() | |||
| 187 | + | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(), | |||
| 188 | + | connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"})) | |||
| 189 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 190 | + | }) | |||
| 191 | + | t.Run("mark read unauthenticated", func(t *testing.T) { | |||
| 192 | + | t.Parallel() | |||
| 193 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 194 | + | mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once() | |||
| 195 | + | _, err := (&mergeRequestService{mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1})) | |||
| 196 | + | requireCode(t, connect.CodeUnauthenticated, err) | |||
| 197 | + | }) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestMergeRequestServiceGet(t *testing.T) { | |||
| 201 | + | t.Parallel() | |||
| 202 | + | tests := []struct { | |||
| 203 | + | name string | |||
| 204 | + | usecase error | |||
| 205 | + | wantCode connect.Code | |||
| 206 | + | }{ | |||
| 207 | + | {name: "ok"}, | |||
| 208 | + | {name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |||
| 209 | + | {name: "unauthenticated", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |||
| 210 | + | } | |||
| 211 | + | for _, tt := range tests { | |||
| 212 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 213 | + | t.Parallel() | |||
| 214 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 215 | + | mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), true). | |||
| 216 | + | Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once() | |||
| 217 | + | resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true})) | |||
| 218 | + | if tt.wantCode != 0 { | |||
| 219 | + | requireCode(t, tt.wantCode, err) | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | assert.EqualValues(t, 4, resp.Msg.GetMergeRequest().GetNumber()) | |||
| 224 | + | assert.True(t, resp.Msg.GetCommitsTruncated()) | |||
| 225 | + | }) | |||
| 226 | + | } | |||
| 227 | + | } | |||
M internal/adapters/rpc/repo.go +6 -6
| @@ -9,12 +9,12 @@ import ( | |||||
| 9 | "connectrpc.com/connect" | 9 | "connectrpc.com/connect" | ||
| 10 | 10 | ||||
| 11 | pb "bnema.dev/ermite-api/gen/ermite" | 11 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 12 | - | "bnema.dev/ermite/internal/app" | |||
| 13 | "bnema.dev/ermite/internal/domain" | 12 | "bnema.dev/ermite/internal/domain" | ||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // repoService implements ermiteconnect.RepoServiceHandler. | 16 | // repoService implements ermiteconnect.RepoServiceHandler. | ||
| 17 | - | type repoService struct{ repos *app.Repos } | 17 | + | type repoService struct{ repos ports.RepoService } |
| 18 | 18 | ||||
| 19 | func (s *repoService) ListRepos(ctx context.Context, req *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | 19 | func (s *repoService) ListRepos(ctx context.Context, req *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | ||
| 20 | page, err := s.repos.ListPage(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken()) | 20 | page, err := s.repos.ListPage(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken()) | ||
| @@ -53,7 +53,7 @@ func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.Cr | |||||
| 53 | if err != nil { | 53 | if err != nil { | ||
| 54 | return nil, connectError(ctx, err) | 54 | return nil, connectError(ctx, err) | ||
| 55 | } | 55 | } | ||
| 56 | - | repo, err := s.repos.Create(ctx, caller(ctx), app.CreateParams{ | 56 | + | repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{ |
| 57 | Name: name, Description: req.Msg.GetDescription(), Visibility: vis, | 57 | Name: name, Description: req.Msg.GetDescription(), Visibility: vis, | ||
| 58 | }) | 58 | }) | ||
| 59 | if err != nil { | 59 | if err != nil { | ||
| @@ -84,8 +84,8 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up | |||||
| 84 | // nothing else. | 84 | // nothing else. | ||
| 85 | // | 85 | // | ||
| 86 | //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here | 86 | //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here | ||
| 87 | - | func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) { | 87 | + | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { |
| 88 | - | var params app.UpdateParams | 88 | + | var params ports.UpdateRepoParams |
| 89 | paths := msg.GetUpdateMask().GetPaths() | 89 | paths := msg.GetUpdateMask().GetPaths() | ||
| 90 | if len(paths) == 0 { | 90 | if len(paths) == 0 { | ||
| 91 | return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid) | 91 | return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid) | ||
| @@ -135,7 +135,7 @@ func (s *repoService) DeleteRepo(ctx context.Context, req *connect.Request[pb.De | |||||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | // respondRepo adds the default branch to repo and wraps it in a response. | 137 | // respondRepo adds the default branch to repo and wraps it in a response. | ||
| 138 | - | func respondRepo[T any](ctx context.Context, repos *app.Repos, repo domain.Repo, wrap func(*pb.Repo) *T) (*connect.Response[T], error) { | 138 | + | func respondRepo[T any](ctx context.Context, repos ports.RepoService, repo domain.Repo, wrap func(*pb.Repo) *T) (*connect.Response[T], error) { |
| 139 | view, err := repos.View(ctx, repo) | 139 | view, err := repos.View(ctx, repo) | ||
| 140 | if err != nil { | 140 | if err != nil { | ||
| 141 | return nil, connectError(ctx, err) | 141 | return nil, connectError(ctx, err) | ||
A internal/adapters/rpc/repo_test.go +254 -0
| @@ -0,0 +1,254 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |||
| 15 | + | ||||
| 16 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // ownerCtx is the context of a call made by the owner, as the interceptor | |||
| 23 | + | // leaves it. | |||
| 24 | + | func ownerCtx() context.Context { | |||
| 25 | + | return domain.ContextWithPrincipal(context.Background(), domain.Owner()) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func mustRepoName(t *testing.T, s string) domain.RepoName { | |||
| 29 | + | t.Helper() | |||
| 30 | + | n, err := domain.ParseRepoName(s) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | return n | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func requireCode(t *testing.T, want connect.Code, err error) { | |||
| 36 | + | t.Helper() | |||
| 37 | + | require.Error(t, err) | |||
| 38 | + | assert.Equal(t, want, connect.CodeOf(err), err.Error()) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestRepoServiceGetRepo(t *testing.T) { | |||
| 42 | + | t.Parallel() | |||
| 43 | + | boom := errors.New("disk on fire") | |||
| 44 | + | tests := []struct { | |||
| 45 | + | name string | |||
| 46 | + | repo string | |||
| 47 | + | usecase error | |||
| 48 | + | viewErr error | |||
| 49 | + | wantCode connect.Code | |||
| 50 | + | wantCall bool | |||
| 51 | + | }{ | |||
| 52 | + | {name: "ok", repo: "demo", wantCall: true}, | |||
| 53 | + | {name: "invalid name never reaches the use case", repo: "../etc", wantCode: connect.CodeInvalidArgument}, | |||
| 54 | + | {name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 55 | + | {name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |||
| 56 | + | {name: "unauthenticated", repo: "demo", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantCall: true}, | |||
| 57 | + | {name: "unknown error is internal and opaque", repo: "demo", usecase: boom, wantCode: connect.CodeInternal, wantCall: true}, | |||
| 58 | + | {name: "view failure", repo: "demo", viewErr: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | repos := mocks.NewRepoService(t) | |||
| 64 | + | if tt.wantCall { | |||
| 65 | + | repo := domain.Repo{Name: mustRepoName(t, "demo"), Description: "d", Visibility: domain.VisibilityPublic} | |||
| 66 | + | repos.EXPECT().Get(mock.Anything, domain.Owner(), repo.Name).Return(repo, tt.usecase).Once() | |||
| 67 | + | if tt.usecase == nil { | |||
| 68 | + | repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo, DefaultBranch: "main"}, tt.viewErr).Once() | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | svc := &repoService{repos} | |||
| 72 | + | resp, err := svc.GetRepo(ownerCtx(), connect.NewRequest(&pb.GetRepoRequest{Repo: tt.repo})) | |||
| 73 | + | if tt.wantCode != 0 { | |||
| 74 | + | requireCode(t, tt.wantCode, err) | |||
| 75 | + | if errors.Is(tt.usecase, boom) { | |||
| 76 | + | assert.NotContains(t, err.Error(), "disk on fire") | |||
| 77 | + | } | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | assert.Equal(t, "demo", resp.Msg.GetRepo().GetName()) | |||
| 82 | + | assert.Equal(t, "main", resp.Msg.GetRepo().GetDefaultBranch()) | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestRepoServiceCreateRepo(t *testing.T) { | |||
| 88 | + | t.Parallel() | |||
| 89 | + | tests := []struct { | |||
| 90 | + | name string | |||
| 91 | + | req *pb.CreateRepoRequest | |||
| 92 | + | usecase error | |||
| 93 | + | wantCode connect.Code | |||
| 94 | + | wantCall bool | |||
| 95 | + | }{ | |||
| 96 | + | {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true}, | |||
| 97 | + | {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument}, | |||
| 98 | + | {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument}, | |||
| 99 | + | {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true}, | |||
| 100 | + | {name: "forbidden", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |||
| 101 | + | } | |||
| 102 | + | for _, tt := range tests { | |||
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 104 | + | t.Parallel() | |||
| 105 | + | repos := mocks.NewRepoService(t) | |||
| 106 | + | if tt.wantCall { | |||
| 107 | + | repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate} | |||
| 108 | + | repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool { | |||
| 109 | + | return p.Name.String() == "demo" | |||
| 110 | + | })).Return(repo, tt.usecase).Once() | |||
| 111 | + | if tt.usecase == nil { | |||
| 112 | + | repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once() | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | resp, err := (&repoService{repos}).CreateRepo(ownerCtx(), connect.NewRequest(tt.req)) | |||
| 116 | + | if tt.wantCode != 0 { | |||
| 117 | + | requireCode(t, tt.wantCode, err) | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | assert.Equal(t, "demo", resp.Msg.GetRepo().GetName()) | |||
| 122 | + | assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, resp.Msg.GetRepo().GetVisibility()) | |||
| 123 | + | }) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestRepoServiceUpdateRepoValidatesMask(t *testing.T) { | |||
| 128 | + | t.Parallel() | |||
| 129 | + | tests := []struct { | |||
| 130 | + | name string | |||
| 131 | + | req *pb.UpdateRepoRequest | |||
| 132 | + | usecase error | |||
| 133 | + | wantCode connect.Code | |||
| 134 | + | wantCall bool | |||
| 135 | + | }{ | |||
| 136 | + | {name: "empty mask", req: &pb.UpdateRepoRequest{Repo: "demo"}, wantCode: connect.CodeInvalidArgument}, | |||
| 137 | + | { | |||
| 138 | + | name: "unknown path", | |||
| 139 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name"}}}, | |||
| 140 | + | wantCode: connect.CodeInvalidArgument, | |||
| 141 | + | }, | |||
| 142 | + | { | |||
| 143 | + | name: "unspecified visibility", | |||
| 144 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}}, | |||
| 145 | + | wantCode: connect.CodeInvalidArgument, | |||
| 146 | + | }, | |||
| 147 | + | { | |||
| 148 | + | name: "invalid repo name", | |||
| 149 | + | req: &pb.UpdateRepoRequest{Repo: "", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}}, | |||
| 150 | + | wantCode: connect.CodeInvalidArgument, | |||
| 151 | + | }, | |||
| 152 | + | { | |||
| 153 | + | name: "not found", | |||
| 154 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}}, | |||
| 155 | + | usecase: domain.ErrNotFound, | |||
| 156 | + | wantCode: connect.CodeNotFound, wantCall: true, | |||
| 157 | + | }, | |||
| 158 | + | { | |||
| 159 | + | name: "precondition failed", | |||
| 160 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"default_branch"}}, DefaultBranch: "nope"}, | |||
| 161 | + | usecase: domain.ErrPreconditionFailed, | |||
| 162 | + | wantCode: connect.CodeFailedPrecondition, wantCall: true, | |||
| 163 | + | }, | |||
| 164 | + | { | |||
| 165 | + | name: "ok", | |||
| 166 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, Description: "new"}, | |||
| 167 | + | wantCall: true, | |||
| 168 | + | }, | |||
| 169 | + | } | |||
| 170 | + | for _, tt := range tests { | |||
| 171 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 172 | + | t.Parallel() | |||
| 173 | + | repos := mocks.NewRepoService(t) | |||
| 174 | + | if tt.wantCall { | |||
| 175 | + | repo := domain.Repo{Name: mustRepoName(t, "demo"), Description: "new"} | |||
| 176 | + | repos.EXPECT().Update(mock.Anything, domain.Owner(), repo.Name, mock.Anything).Return(repo, tt.usecase).Once() | |||
| 177 | + | if tt.usecase == nil { | |||
| 178 | + | repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once() | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | resp, err := (&repoService{repos}).UpdateRepo(ownerCtx(), connect.NewRequest(tt.req)) | |||
| 182 | + | if tt.wantCode != 0 { | |||
| 183 | + | requireCode(t, tt.wantCode, err) | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | require.NoError(t, err) | |||
| 187 | + | assert.Equal(t, "new", resp.Msg.GetRepo().GetDescription()) | |||
| 188 | + | }) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestRepoServiceDeleteRepo(t *testing.T) { | |||
| 193 | + | t.Parallel() | |||
| 194 | + | tests := []struct { | |||
| 195 | + | name string | |||
| 196 | + | repo string | |||
| 197 | + | usecase error | |||
| 198 | + | wantCode connect.Code | |||
| 199 | + | wantCall bool | |||
| 200 | + | }{ | |||
| 201 | + | {name: "ok", repo: "demo", wantCall: true}, | |||
| 202 | + | {name: "invalid name", repo: "a/../b", wantCode: connect.CodeInvalidArgument}, | |||
| 203 | + | {name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |||
| 204 | + | {name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |||
| 205 | + | } | |||
| 206 | + | for _, tt := range tests { | |||
| 207 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 208 | + | t.Parallel() | |||
| 209 | + | repos := mocks.NewRepoService(t) | |||
| 210 | + | if tt.wantCall { | |||
| 211 | + | repos.EXPECT().Delete(mock.Anything, domain.Owner(), mustRepoName(t, tt.repo)).Return(tt.usecase).Once() | |||
| 212 | + | } | |||
| 213 | + | _, err := (&repoService{repos}).DeleteRepo(ownerCtx(), connect.NewRequest(&pb.DeleteRepoRequest{Repo: tt.repo})) | |||
| 214 | + | if tt.wantCode != 0 { | |||
| 215 | + | requireCode(t, tt.wantCode, err) | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | require.NoError(t, err) | |||
| 219 | + | }) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestRepoServiceListRepos(t *testing.T) { | |||
| 224 | + | t.Parallel() | |||
| 225 | + | t.Run("pages and views every repo", func(t *testing.T) { | |||
| 226 | + | t.Parallel() | |||
| 227 | + | repos := mocks.NewRepoService(t) | |||
| 228 | + | a := domain.Repo{Name: mustRepoName(t, "a")} | |||
| 229 | + | b := domain.Repo{Name: mustRepoName(t, "b")} | |||
| 230 | + | repos.EXPECT().ListPage(mock.Anything, domain.Owner(), 2, "tok").Return(ports.RepoPage{Repos: []domain.Repo{a, b}, NextPageToken: "next"}, nil).Once() | |||
| 231 | + | repos.EXPECT().View(mock.Anything, a).Return(ports.RepoView{Repo: a}, nil).Once() | |||
| 232 | + | repos.EXPECT().View(mock.Anything, b).Return(ports.RepoView{Repo: b}, nil).Once() | |||
| 233 | + | resp, err := (&repoService{repos}).ListRepos(ownerCtx(), connect.NewRequest(&pb.ListReposRequest{PageSize: 2, PageToken: "tok"})) | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | require.Len(t, resp.Msg.GetRepos(), 2) | |||
| 236 | + | assert.Equal(t, "next", resp.Msg.GetNextPageToken()) | |||
| 237 | + | }) | |||
| 238 | + | for name, tc := range map[string]struct { | |||
| 239 | + | err error | |||
| 240 | + | code connect.Code | |||
| 241 | + | }{ | |||
| 242 | + | "bad page token": {domain.ErrInvalidPageToken, connect.CodeInvalidArgument}, | |||
| 243 | + | "bad page size": {domain.ErrInvalidPageSize, connect.CodeInvalidArgument}, | |||
| 244 | + | "unauthenticated": {domain.ErrUnauthenticated, connect.CodeUnauthenticated}, | |||
| 245 | + | } { | |||
| 246 | + | t.Run(name, func(t *testing.T) { | |||
| 247 | + | t.Parallel() | |||
| 248 | + | repos := mocks.NewRepoService(t) | |||
| 249 | + | repos.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, tc.err).Once() | |||
| 250 | + | _, err := (&repoService{repos}).ListRepos(ownerCtx(), connect.NewRequest(&pb.ListReposRequest{})) | |||
| 251 | + | requireCode(t, tc.code, err) | |||
| 252 | + | }) | |||
| 253 | + | } | |||
| 254 | + | } | |||
M internal/adapters/rpc/server.go +2 -2
| @@ -9,13 +9,13 @@ import ( | |||||
| 9 | "connectrpc.com/connect" | 9 | "connectrpc.com/connect" | ||
| 10 | 10 | ||||
| 11 | pb "bnema.dev/ermite-api/gen/ermite" | 11 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 12 | - | "bnema.dev/ermite/internal/app" | 12 | + | "bnema.dev/ermite/internal/ports" |
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // serverService implements ermiteconnect.ServerServiceHandler. | 15 | // serverService implements ermiteconnect.ServerServiceHandler. | ||
| 16 | type serverService struct { | 16 | type serverService struct { | ||
| 17 | version string | 17 | version string | ||
| 18 | - | key *app.SigningKey | 18 | + | key ports.SigningKeyService |
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | 21 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | ||
A internal/adapters/rpc/server_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestServerServiceGetVersion(t *testing.T) { | |||
| 19 | + | t.Parallel() | |||
| 20 | + | svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)} | |||
| 21 | + | resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | assert.Equal(t, "v1.2.3", resp.Msg.GetVersion()) | |||
| 24 | + | assert.Equal(t, Features, resp.Msg.GetFeatures()) | |||
| 25 | + | ||||
| 26 | + | // The caller gets a copy: changing it does not change what is advertised. | |||
| 27 | + | resp.Msg.Features[0] = "tampered" | |||
| 28 | + | assert.NotEqual(t, "tampered", Features[0]) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestServerServiceGetSigningKey(t *testing.T) { | |||
| 32 | + | t.Parallel() | |||
| 33 | + | key := mocks.NewSigningKeyService(t) | |||
| 34 | + | key.EXPECT().Info().Return(domain.SigningKeyInfo{ | |||
| 35 | + | Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA", Fingerprint: "SHA256:xyz"}, | |||
| 36 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@example.com"}, | |||
| 37 | + | }).Once() | |||
| 38 | + | resp, err := (&serverService{key: key}).GetSigningKey(context.Background(), connect.NewRequest(&pb.GetSigningKeyRequest{})) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | assert.Equal(t, "ssh-ed25519 AAAA", resp.Msg.GetPublicKey()) | |||
| 41 | + | assert.Equal(t, "SHA256:xyz", resp.Msg.GetFingerprint()) | |||
| 42 | + | assert.Equal(t, "ermite", resp.Msg.GetCommitterName()) | |||
| 43 | + | assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail()) | |||
| 44 | + | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | |||
| 45 | + | } | |||
M internal/adapters/rpc/session.go +2 -2
| @@ -8,12 +8,12 @@ import ( | |||||
| 8 | "connectrpc.com/connect" | 8 | "connectrpc.com/connect" | ||
| 9 | 9 | ||||
| 10 | pb "bnema.dev/ermite-api/gen/ermite" | 10 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 11 | - | "bnema.dev/ermite/internal/app" | 11 | + | "bnema.dev/ermite/internal/ports" |
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | 14 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | ||
| 15 | // never calls it: sessions come from the link, and the API ignores cookies. | 15 | // never calls it: sessions come from the link, and the API ignores cookies. | ||
| 16 | - | type sessionService struct{ sessions *app.Sessions } | 16 | + | type sessionService struct{ sessions ports.SessionService } |
| 17 | 17 | ||||
| 18 | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | 18 | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | ||
| 19 | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | 19 | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | ||
A internal/adapters/rpc/session_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestSessionServiceCreateLoginLink(t *testing.T) { | |||
| 21 | + | t.Parallel() | |||
| 22 | + | exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |||
| 23 | + | tests := []struct { | |||
| 24 | + | name string | |||
| 25 | + | usecase error | |||
| 26 | + | wantCode connect.Code | |||
| 27 | + | }{ | |||
| 28 | + | {name: "ok"}, | |||
| 29 | + | {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |||
| 30 | + | {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |||
| 31 | + | } | |||
| 32 | + | for _, tt := range tests { | |||
| 33 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 34 | + | t.Parallel() | |||
| 35 | + | sessions := mocks.NewSessionService(t) | |||
| 36 | + | sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()). | |||
| 37 | + | Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once() | |||
| 38 | + | resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |||
| 39 | + | if tt.wantCode != 0 { | |||
| 40 | + | requireCode(t, tt.wantCode, err) | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | assert.Equal(t, "https://x/-/login/n", resp.Msg.GetUrl()) | |||
| 45 | + | assert.True(t, exp.Equal(resp.Msg.GetExpiresAt().AsTime())) | |||
| 46 | + | }) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestSessionServiceSignOutEverywhere(t *testing.T) { | |||
| 51 | + | t.Parallel() | |||
| 52 | + | tests := []struct { | |||
| 53 | + | name string | |||
| 54 | + | usecase error | |||
| 55 | + | wantCode connect.Code | |||
| 56 | + | }{ | |||
| 57 | + | {name: "ok"}, | |||
| 58 | + | {name: "forbidden", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | sessions := mocks.NewSessionService(t) | |||
| 64 | + | sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once() | |||
| 65 | + | _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |||
| 66 | + | if tt.wantCode != 0 { | |||
| 67 | + | requireCode(t, tt.wantCode, err) | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M internal/adapters/rpc/token.go +3 -3
| @@ -8,15 +8,15 @@ import ( | |||||
| 8 | "connectrpc.com/connect" | 8 | "connectrpc.com/connect" | ||
| 9 | 9 | ||||
| 10 | pb "bnema.dev/ermite-api/gen/ermite" | 10 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 11 | - | "bnema.dev/ermite/internal/app" | |||
| 12 | "bnema.dev/ermite/internal/domain" | 11 | "bnema.dev/ermite/internal/domain" | ||
| 12 | + | "bnema.dev/ermite/internal/ports" | |||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // tokenService implements ermiteconnect.TokenServiceHandler. | 15 | // tokenService implements ermiteconnect.TokenServiceHandler. | ||
| 16 | - | type tokenService struct{ tokens *app.Tokens } | 16 | + | type tokenService struct{ tokens ports.TokenService } |
| 17 | 17 | ||||
| 18 | func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.CreateTokenRequest]) (*connect.Response[pb.CreateTokenResponse], error) { | 18 | func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.CreateTokenRequest]) (*connect.Response[pb.CreateTokenResponse], error) { | ||
| 19 | - | params := app.CreateTokenParams{ | 19 | + | params := ports.CreateTokenParams{ |
| 20 | Label: req.Msg.GetLabel(), | 20 | Label: req.Msg.GetLabel(), | ||
| 21 | ExpiresAt: goTime(req.Msg.GetExpiresAt()), | 21 | ExpiresAt: goTime(req.Msg.GetExpiresAt()), | ||
| 22 | } | 22 | } | ||
A internal/adapters/rpc/token_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "connectrpc.com/connect" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestTokenServiceCreateToken(t *testing.T) { | |||
| 20 | + | t.Parallel() | |||
| 21 | + | tests := []struct { | |||
| 22 | + | name string | |||
| 23 | + | req *pb.CreateTokenRequest | |||
| 24 | + | usecase error | |||
| 25 | + | wantCode connect.Code | |||
| 26 | + | wantCall bool | |||
| 27 | + | }{ | |||
| 28 | + | {name: "ok", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"demo"}}, wantCall: true}, | |||
| 29 | + | {name: "unspecified scope", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_UNSPECIFIED}}, wantCode: connect.CodeInvalidArgument}, | |||
| 30 | + | {name: "invalid repo name", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"../x"}}, wantCode: connect.CodeInvalidArgument}, | |||
| 31 | + | {name: "forbidden", req: &pb.CreateTokenRequest{Label: "ci"}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |||
| 32 | + | {name: "anonymous", req: &pb.CreateTokenRequest{Label: "ci"}, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantCall: true}, | |||
| 33 | + | {name: "invalid fields", req: &pb.CreateTokenRequest{}, usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |||
| 34 | + | } | |||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | t.Parallel() | |||
| 38 | + | tokens := mocks.NewTokenService(t) | |||
| 39 | + | if tt.wantCall { | |||
| 40 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything). | |||
| 41 | + | Return(ports.CreatedToken{Token: domain.Token{ID: "t1", Label: "ci"}, Secret: "s3cret"}, tt.usecase).Once() | |||
| 42 | + | } | |||
| 43 | + | resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(tt.req)) | |||
| 44 | + | if tt.wantCode != 0 { | |||
| 45 | + | requireCode(t, tt.wantCode, err) | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.Equal(t, "s3cret", resp.Msg.GetSecret()) | |||
| 50 | + | assert.Equal(t, "t1", resp.Msg.GetToken().GetId()) | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestTokenServiceCreateTokenPassesParams(t *testing.T) { | |||
| 56 | + | t.Parallel() | |||
| 57 | + | tokens := mocks.NewTokenService(t) | |||
| 58 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool { | |||
| 59 | + | return p.Label == "ci" && len(p.Scopes) == 1 && len(p.Repos) == 1 && p.Repos[0].String() == "demo" | |||
| 60 | + | })).Return(ports.CreatedToken{}, nil).Once() | |||
| 61 | + | _, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |||
| 62 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"demo"}, | |||
| 63 | + | })) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestTokenServiceRevokeToken(t *testing.T) { | |||
| 68 | + | t.Parallel() | |||
| 69 | + | tests := []struct { | |||
| 70 | + | name string | |||
| 71 | + | usecase error | |||
| 72 | + | wantCode connect.Code | |||
| 73 | + | }{ | |||
| 74 | + | {name: "ok"}, | |||
| 75 | + | {name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |||
| 76 | + | {name: "forbidden", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |||
| 77 | + | } | |||
| 78 | + | for _, tt := range tests { | |||
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | + | t.Parallel() | |||
| 81 | + | tokens := mocks.NewTokenService(t) | |||
| 82 | + | tokens.EXPECT().Revoke(mock.Anything, domain.Owner(), "t1").Return(tt.usecase).Once() | |||
| 83 | + | _, err := (&tokenService{tokens}).RevokeToken(ownerCtx(), connect.NewRequest(&pb.RevokeTokenRequest{Id: "t1"})) | |||
| 84 | + | if tt.wantCode != 0 { | |||
| 85 | + | requireCode(t, tt.wantCode, err) | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestTokenServiceListTokens(t *testing.T) { | |||
| 94 | + | t.Parallel() | |||
| 95 | + | tokens := mocks.NewTokenService(t) | |||
| 96 | + | tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 5, "tok"). | |||
| 97 | + | Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a"}, {ID: "b"}}, NextPageToken: "n"}, nil).Once() | |||
| 98 | + | resp, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{PageSize: 5, PageToken: "tok"})) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | assert.Len(t, resp.Msg.GetTokens(), 2) | |||
| 101 | + | assert.Equal(t, "n", resp.Msg.GetNextPageToken()) | |||
| 102 | + | ||||
| 103 | + | tokens.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.TokenPage{}, domain.ErrInvalidPageToken).Once() | |||
| 104 | + | _, err = (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{})) | |||
| 105 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 106 | + | } | |||
M internal/adapters/web/ASSETS.md +3 -3
| @@ -8,18 +8,18 @@ content-hashed names. Nothing is fetched at runtime. | |||||
| 8 | | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` | | 8 | | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` | | ||
| 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | ||
| 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | ||
| 11 | - | | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | | | 11 | + | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | |
| 12 | | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | | | 12 | | `static/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 | | | ||
| 13 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | 13 | | `static/css/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 | | | ||
| 14 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | 14 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | ||
| 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` | | 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` | | ||
| 16 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | 16 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | ||
| 17 | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is | written here | | | 17 | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is | written here | | | ||
| 18 | - | | `static/rules.json` | speculation rules: prefetch same-origin pages on hover (`Speculation-Rules` header) | written here | | | 18 | + | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | |
| 19 | 19 | ||||
| 20 | The three htmx files are the exact files the approved mockup was built with. | 20 | The three htmx files are the exact files the approved mockup was built with. | ||
| 21 | To upgrade, replace the file, update the checksum here, and run the web tests | 21 | To upgrade, replace the file, update the checksum here, and run the web tests | ||
| 22 | - | (`go test ./cmd/ermite -run TestWeb`). | 22 | + | (`go test ./internal/app -run TestWeb`). |
| 23 | 23 | ||||
| 24 | `*_templ.go` files are generated by `go tool templ generate` (templ is pinned | 24 | `*_templ.go` files are generated by `go tool templ generate` (templ is pinned | ||
| 25 | in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale. | 25 | in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale. | ||
M internal/adapters/web/assets.go +29 -3
| @@ -34,6 +34,9 @@ type Assets struct { | |||||
| 34 | type asset struct { | 34 | type asset struct { | ||
| 35 | body []byte | 35 | body []byte | ||
| 36 | typ string | 36 | typ string | ||
| 37 | + | // gz is the gzip form of body, compressed once at load; nil when the type | |||
| 38 | + | // is already compressed or gzip does not make the body smaller. | |||
| 39 | + | gz []byte | |||
| 37 | } | 40 | } | ||
| 38 | 41 | ||||
| 39 | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | 42 | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | ||
| @@ -71,7 +74,13 @@ func loadAssets() (*assetSet, error) { | |||||
| 71 | sum := sha256.Sum256(body) | 74 | sum := sha256.Sum256(body) | ||
| 72 | ext := path.Ext(file) | 75 | ext := path.Ext(file) | ||
| 73 | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | 76 | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | ||
| 74 | - | s.byName[name] = asset{body: body, typ: typ} | 77 | + | a := asset{body: body, typ: typ} |
| 78 | + | if compressible(typ) { | |||
| 79 | + | if z, err := gzipBest(body); err == nil && len(z) < len(body) { | |||
| 80 | + | a.gz = z | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | s.byName[name] = a | |||
| 75 | return StaticPrefix + name | 84 | return StaticPrefix + name | ||
| 76 | } | 85 | } | ||
| 77 | 86 | ||||
| @@ -149,8 +158,25 @@ func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) { | |||||
| 149 | h := w.Header() | 158 | h := w.Header() | ||
| 150 | h.Set("Content-Type", a.typ) | 159 | h.Set("Content-Type", a.typ) | ||
| 151 | h.Set("Cache-Control", "public, max-age=31536000, immutable") | 160 | h.Set("Cache-Control", "public, max-age=31536000, immutable") | ||
| 152 | - | h.Set("Content-Length", itoa(len(a.body))) | 161 | + | body := a.body |
| 162 | + | if a.gz != nil { | |||
| 163 | + | addVary(h, "Accept-Encoding") | |||
| 164 | + | if acceptsGzip(r.Header) { | |||
| 165 | + | body = a.gz | |||
| 166 | + | h.Set("Content-Encoding", "gzip") | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | h.Set("Content-Length", itoa(len(body))) | |||
| 153 | if r.Method != http.MethodHead { | 170 | if r.Method != http.MethodHead { | ||
| 154 | - | _, _ = w.Write(a.body) | 171 | + | _, _ = w.Write(body) |
| 155 | } | 172 | } | ||
| 156 | } | 173 | } | ||
| 174 | + | ||||
| 175 | + | // compressible tells whether a content type is worth gzip: text and | |||
| 176 | + | // structured text. Fonts (woff2) and raster images are compressed already. | |||
| 177 | + | func compressible(typ string) bool { | |||
| 178 | + | mt, _, _ := strings.Cut(typ, ";") | |||
| 179 | + | mt = strings.TrimSpace(strings.ToLower(mt)) | |||
| 180 | + | return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" || | |||
| 181 | + | mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json") | |||
| 182 | + | } | |||
M internal/adapters/web/blob.go +38 -39
| @@ -4,17 +4,18 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "html/template" | 9 | "html/template" | ||
| 10 | + | "io" | |||
| 9 | "net/http" | 11 | "net/http" | ||
| 10 | "path" | 12 | "path" | ||
| 11 | "strings" | 13 | "strings" | ||
| 12 | 14 | ||||
| 13 | - | "connectrpc.com/connect" | |||
| 14 | "github.com/a-h/templ" | 15 | "github.com/a-h/templ" | ||
| 15 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 16 | 17 | ||||
| 17 | - | pb "bnema.dev/ermite-api/gen/ermite" | 18 | + | "bnema.dev/ermite/internal/domain" |
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | const ( | 21 | const ( | ||
| @@ -45,43 +46,43 @@ type BlobPage struct { | |||||
| 45 | DirHref string | 46 | DirHref string | ||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| 48 | - | // blobStream is an open GetBlob stream whose first message has been read. | 49 | + | // blobStream is an open file: its metadata and the content, read on demand. |
| 49 | type blobStream struct { | 50 | type blobStream struct { | ||
| 50 | - | stream *connect.ServerStreamForClient[pb.GetBlobResponse] | 51 | + | body io.ReadCloser |
| 51 | - | info *pb.BlobInfo | 52 | + | info domain.BlobInfo |
| 52 | - | // buf holds the content already received and not yet handed out. | 53 | + | // buf holds the content already read and not yet handed out. |
| 53 | buf []byte | 54 | buf []byte | ||
| 55 | + | // eof is true once body has nothing more. | |||
| 56 | + | eof bool | |||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 56 | - | // openBlob starts the stream of a file and reads its metadata. | 59 | + | // openBlob opens a file of the repository. |
| 57 | - | func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) { | 60 | + | func (s *Server) openBlob(ctx context.Context, repo domain.RepoName, t target) (*blobStream, error) { |
| 58 | - | if t.path == "" { | 61 | + | info, body, err := s.openBlobAt(ctx, repo, t.rev, t.path) |
| 59 | - | return nil, errNotFound | |||
| 60 | - | } | |||
| 61 | - | st, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: t.rev, Path: t.path})) | |||
| 62 | if err != nil { | 62 | if err != nil { | ||
| 63 | return nil, err | 63 | return nil, err | ||
| 64 | } | 64 | } | ||
| 65 | - | if !st.Receive() { | 65 | + | return &blobStream{body: body, info: info}, nil |
| 66 | - | err := st.Err() | |||
| 67 | - | _ = st.Close() | |||
| 68 | - | if err == nil { | |||
| 69 | - | err = errNotFound | |||
| 70 | - | } | |||
| 71 | - | return nil, err | |||
| 72 | - | } | |||
| 73 | - | m := st.Msg() | |||
| 74 | - | return &blobStream{stream: st, info: m.GetInfo(), buf: append([]byte(nil), m.GetChunk()...)}, nil | |||
| 75 | } | 66 | } | ||
| 76 | 67 | ||||
| 77 | - | func (b *blobStream) close() { _ = b.stream.Close() } | 68 | + | func (b *blobStream) close() { _ = b.body.Close() } |
| 78 | 69 | ||||
| 79 | - | // fill reads from the stream until buf holds n bytes or the content ends. | 70 | + | // fill reads until buf holds n bytes or the content ends. |
| 80 | func (b *blobStream) fill(n int) error { | 71 | func (b *blobStream) fill(n int) error { | ||
| 81 | - | for len(b.buf) < n && b.stream.Receive() { | 72 | + | if b.eof || len(b.buf) >= n { |
| 82 | - | b.buf = append(b.buf, b.stream.Msg().GetChunk()...) | 73 | + | return nil |
| 74 | + | } | |||
| 75 | + | // One allocation for the whole content, not the doublings of append. | |||
| 76 | + | if cap(b.buf) < n { | |||
| 77 | + | b.buf = append(make([]byte, 0, n), b.buf...) | |||
| 78 | + | } | |||
| 79 | + | m, err := io.ReadFull(b.body, b.buf[len(b.buf):n]) | |||
| 80 | + | b.buf = b.buf[:len(b.buf)+m] | |||
| 81 | + | if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { | |||
| 82 | + | b.eof = true | |||
| 83 | + | return nil | |||
| 83 | } | 84 | } | ||
| 84 | - | return b.stream.Err() | 85 | + | return err |
| 85 | } | 86 | } | ||
| 86 | 87 | ||||
| 87 | // image types: the only images the pages show and raw serves as images. | 88 | // image types: the only images the pages show and raw serves as images. | ||
| @@ -110,7 +111,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 110 | if !ok { | 111 | if !ok { | ||
| 111 | return nil, 0, errNotFound | 112 | return nil, 0, errNotFound | ||
| 112 | } | 113 | } | ||
| 113 | - | b, err := s.openBlob(ctx, d.name(), t) | 114 | + | b, err := s.openBlob(ctx, d.repo.Name, t) |
| 114 | if err != nil { | 115 | if err != nil { | ||
| 115 | return nil, 0, err | 116 | return nil, 0, err | ||
| 116 | } | 117 | } | ||
| @@ -121,7 +122,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 121 | if dir == "." { | 122 | if dir == "." { | ||
| 122 | dir = "" | 123 | dir = "" | ||
| 123 | } | 124 | } | ||
| 124 | - | size := b.info.GetSize() | 125 | + | size := b.info.Size |
| 125 | p := BlobPage{ | 126 | p := BlobPage{ | ||
| 126 | Name: visibleText(name), | 127 | Name: visibleText(name), | ||
| 127 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | 128 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | ||
| @@ -129,7 +130,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 129 | DirHref: repoHref(d.name(), "tree", t.name, dir), | 130 | DirHref: repoHref(d.name(), "tree", t.name, dir), | ||
| 130 | Facts: []Fact{{Key: "size", Val: humanSize(size)}}, | 131 | Facts: []Fact{{Key: "size", Val: humanSize(size)}}, | ||
| 131 | } | 132 | } | ||
| 132 | - | typ := b.info.GetMediaType() | 133 | + | typ := b.info.MediaType |
| 133 | lang := "" | 134 | lang := "" | ||
| 134 | 135 | ||||
| 135 | // An image is recognised by its bytes, whatever Git's text rule says. | 136 | // An image is recognised by its bytes, whatever Git's text rule says. | ||
| @@ -142,7 +143,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 142 | switch { | 143 | switch { | ||
| 143 | case img != "": | 144 | case img != "": | ||
| 144 | p.Image, typ = true, img | 145 | p.Image, typ = true, img | ||
| 145 | - | case b.info.GetBinary(): | 146 | + | case b.info.Binary: |
| 146 | p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size)) | 147 | p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size)) | ||
| 147 | case size > maxCodeBytes: | 148 | case size > maxCodeBytes: | ||
| 148 | p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes)) | 149 | p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes)) | ||
| @@ -180,14 +181,14 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 180 | } | 181 | } | ||
| 181 | p.Facts = append(p.Facts, Fact{Key: "type", Val: lang}) | 182 | p.Facts = append(p.Facts, Fact{Key: "type", Val: lang}) | ||
| 182 | 183 | ||||
| 183 | - | if last := s.lastChange(ctx, d.name(), t); last != nil { | 184 | + | if last := s.lastChange(ctx, d.repo.Name, t); last != nil { |
| 184 | p.Last = last | 185 | p.Last = last | ||
| 185 | } | 186 | } | ||
| 186 | 187 | ||||
| 187 | l := s.repoLayout(ctx, d, "tree") | 188 | l := s.repoLayout(ctx, d, "tree") | ||
| 188 | // A blob stream does not name its commit: an abbreviated id is resolved | 189 | // A blob stream does not name its commit: an abbreviated id is resolved | ||
| 189 | // for the finder. | 190 | // for the finder. | ||
| 190 | - | s.resolveCommit(ctx, d.name(), &t) | 191 | + | s.resolveCommit(ctx, d.repo.Name, &t) |
| 191 | pinRef(&l, t) | 192 | pinRef(&l, t) | ||
| 192 | l.Crumbs = pathCrumbs(d.name(), "blob", t) | 193 | l.Crumbs = pathCrumbs(d.name(), "blob", t) | ||
| 193 | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | 194 | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | ||
| @@ -197,14 +198,12 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 197 | 198 | ||||
| 198 | // lastChange is the latest commit that touched the path, nil when it cannot | 199 | // lastChange is the latest commit that touched the path, nil when it cannot | ||
| 199 | // be found (the page does not depend on it). | 200 | // be found (the page does not depend on it). | ||
| 200 | - | func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow { | 201 | + | func (s *Server) lastChange(ctx context.Context, repo domain.RepoName, t target) *CommitRow { |
| 201 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 202 | + | res, err := s.commitList(ctx, repo, t.rev, t.path, 1, "") |
| 202 | - | Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1, | 203 | + | if err != nil || len(res.Commits) != 1 { |
| 203 | - | })) | |||
| 204 | - | if err != nil || len(res.Msg.GetCommits()) != 1 { | |||
| 205 | return nil | 204 | return nil | ||
| 206 | } | 205 | } | ||
| 207 | - | row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now()) | 206 | + | row := commitRow(repo.String(), res.Commits[0], s.cfg.Now()) |
| 208 | return &row | 207 | return &row | ||
| 209 | } | 208 | } | ||
| 210 | 209 | ||||
M internal/adapters/web/blob.templ +1 -1
| @@ -54,7 +54,7 @@ templ CodeView(h template.HTML) { | |||||
| 54 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" | 54 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" | ||
| 55 | hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" | 55 | hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" | ||
| 56 | data-ready="" | 56 | data-ready="" | ||
| 57 | - | ><code>@templ.Raw(string(h))</code></pre> | 57 | + | ><code hx-ignore>@templ.Raw(string(h))</code></pre> |
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | templ blobRail(p BlobPage) { | 60 | templ blobRail(p BlobPage) { | ||
M internal/adapters/web/blob_templ.go +1 -1 generated file: collapsed
A internal/adapters/web/browse.go +91 -0
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite/internal/domain" | |||
| 10 | + | "bnema.dev/ermite/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // The reads of the pages go through BrowseService as the caller of the | |||
| 14 | + | // request. The revisions and paths the URLs carry are text: they are parsed | |||
| 15 | + | // here, and one the domain refuses is an invalid argument, which the pages | |||
| 16 | + | // answer like a missing one (fail). | |||
| 17 | + | ||||
| 18 | + | // commitList reads a page of the history that starts at rev, limited to path | |||
| 19 | + | // when it is not empty. | |||
| 20 | + | func (s *Server) commitList(ctx context.Context, repo domain.RepoName, rev, path string, size int, token string) (ports.HistoryPage, error) { | |||
| 21 | + | r, err := domain.ParseRevision(rev) | |||
| 22 | + | if err != nil { | |||
| 23 | + | return ports.HistoryPage{}, err | |||
| 24 | + | } | |||
| 25 | + | p, err := domain.ParseTreePath(path) | |||
| 26 | + | if err != nil { | |||
| 27 | + | return ports.HistoryPage{}, err | |||
| 28 | + | } | |||
| 29 | + | return s.cfg.Browse.ListCommits(ctx, principal(ctx), repo, ports.ListCommitsParams{ | |||
| 30 | + | Revision: r, Path: p, PageSize: size, PageToken: token, | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // getTree lists the directory path of the commit rev. | |||
| 35 | + | func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path string, lastCommit bool) (domain.Tree, error) { | |||
| 36 | + | r, err := domain.ParseRevision(rev) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return domain.Tree{}, err | |||
| 39 | + | } | |||
| 40 | + | p, err := domain.ParseTreePath(path) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return domain.Tree{}, err | |||
| 43 | + | } | |||
| 44 | + | return s.cfg.Browse.GetTree(ctx, principal(ctx), repo, r, p, lastCommit) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // getCommit reads a commit with its diff. | |||
| 48 | + | func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) { | |||
| 49 | + | r, err := domain.ParseRevision(rev) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return domain.CommitDetail{}, err | |||
| 52 | + | } | |||
| 53 | + | return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // openBlobAt opens the file path of the commit rev. The caller closes the | |||
| 57 | + | // body. | |||
| 58 | + | func (s *Server) openBlobAt(ctx context.Context, repo domain.RepoName, rev, path string) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 59 | + | r, err := domain.ParseRevision(rev) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return domain.BlobInfo{}, nil, err | |||
| 62 | + | } | |||
| 63 | + | if path == "" { | |||
| 64 | + | return domain.BlobInfo{}, nil, errNotFound | |||
| 65 | + | } | |||
| 66 | + | p, err := domain.ParseTreePath(path) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return domain.BlobInfo{}, nil, err | |||
| 69 | + | } | |||
| 70 | + | return s.cfg.Browse.OpenBlob(ctx, principal(ctx), repo, r, p) | |||
| 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 | + | } | |||
M internal/adapters/web/commit.go +38 -39
| @@ -9,10 +9,9 @@ import ( | |||||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | - | "connectrpc.com/connect" | |||
| 13 | "github.com/a-h/templ" | 12 | "github.com/a-h/templ" | ||
| 14 | 13 | ||||
| 15 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/domain" |
| 16 | ) | 15 | ) | ||
| 17 | 16 | ||||
| 18 | // bothViewsLines is the largest diff, in lines, that is sent in both the | 17 | // bothViewsLines is the largest diff, in lines, that is sent in both the | ||
| @@ -91,6 +90,8 @@ func diffLines(files []DiffFile) (lines int, invisible bool) { | |||||
| 91 | 90 | ||||
| 92 | // commit shows a commit: its message, its totals, the changed files and the | 91 | // commit shows a commit: its message, its totals, the changed files and the | ||
| 93 | // diff against the first parent. ?view=split selects the split layout. | 92 | // diff against the first parent. ?view=split selects the split layout. | ||
| 93 | + | // | |||
| 94 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here | |||
| 94 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 95 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 95 | sha := r.PathValue("sha") | 96 | sha := r.PathValue("sha") | ||
| 96 | if !isCommitID(sha) { | 97 | if !isCommitID(sha) { | ||
| @@ -100,57 +101,55 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 100 | if err != nil { | 101 | if err != nil { | ||
| 101 | return nil, 0, err | 102 | return nil, 0, err | ||
| 102 | } | 103 | } | ||
| 103 | - | res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: d.name(), Revision: sha})) | 104 | + | res, err := s.getCommit(ctx, d.repo.Name, sha) |
| 104 | if err != nil { | 105 | if err != nil { | ||
| 105 | return nil, 0, err | 106 | return nil, 0, err | ||
| 106 | } | 107 | } | ||
| 107 | - | c := res.Msg.GetCommit() | 108 | + | c := res.Commit |
| 108 | - | if c == nil { | 109 | + | cid := c.ID.String() |
| 109 | - | return nil, 0, errNotFound | |||
| 110 | - | } | |||
| 111 | - | stats := res.Msg.GetStats() | |||
| 112 | now := s.cfg.Now() | 110 | now := s.cfg.Now() | ||
| 113 | 111 | ||||
| 114 | p := CommitPage{ | 112 | p := CommitPage{ | ||
| 115 | - | SHA: c.GetId(), Short: short(c.GetId()), | 113 | + | SHA: cid, Short: short(cid), |
| 116 | - | Subject: visibleText(c.GetSummary()), Body: commitBody(c), | 114 | + | Subject: visibleText(c.Summary), Body: commitBody(c), |
| 117 | - | Author: person(c.GetAuthor(), now), | 115 | + | Author: person(c.Author, now), |
| 118 | - | Merge: len(c.GetParentIds()) > 1, | 116 | + | Merge: len(c.Parents) > 1, |
| 119 | - | Root: len(c.GetParentIds()) == 0, | 117 | + | Root: len(c.Parents) == 0, |
| 120 | - | NFiles: int(stats.GetFilesChanged()), | 118 | + | NFiles: res.Stats.FilesChanged, |
| 121 | - | Adds: int(stats.GetAdditions()), | 119 | + | Adds: res.Stats.Additions, |
| 122 | - | Dels: int(stats.GetDeletions()), | 120 | + | Dels: res.Stats.Deletions, |
| 123 | - | Files: diffFiles(res.Msg.GetDiff().GetFiles()), | 121 | + | Files: diffFiles(res.Diff.Files), |
| 124 | - | } | 122 | + | } |
| 125 | - | if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() { | 123 | + | if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email { |
| 126 | p.Committer = &cm | 124 | p.Committer = &cm | ||
| 127 | } | 125 | } | ||
| 128 | - | for _, id := range c.GetParentIds() { | 126 | + | for _, pid := range c.Parents { |
| 127 | + | id := pid.String() | |||
| 129 | p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)}) | 128 | p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)}) | ||
| 130 | } | 129 | } | ||
| 131 | for _, b := range d.branches { | 130 | for _, b := range d.branches { | ||
| 132 | - | if b.GetCommitId() == c.GetId() { | 131 | + | if b.CommitID.String() == cid { |
| 133 | - | p.Refs = append(p.Refs, b.GetShortName()) | 132 | + | p.Refs = append(p.Refs, b.ShortName) |
| 134 | } | 133 | } | ||
| 135 | } | 134 | } | ||
| 136 | for _, t := range d.tags { | 135 | for _, t := range d.tags { | ||
| 137 | - | if t.GetCommitId() == c.GetId() { | 136 | + | if t.CommitID.String() == cid { |
| 138 | - | p.Refs = append(p.Refs, "tag: "+t.GetShortName()) | 137 | + | p.Refs = append(p.Refs, "tag: "+t.ShortName) |
| 139 | } | 138 | } | ||
| 140 | } | 139 | } | ||
| 141 | - | p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles) | 140 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) |
| 142 | 141 | ||||
| 143 | lines, invisible := diffLines(p.Files) | 142 | lines, invisible := diffLines(p.Files) | ||
| 144 | p.Invisible = invisible | 143 | p.Invisible = invisible | ||
| 145 | - | base := repoHref(d.name(), "commit", c.GetId()) | 144 | + | base := repoHref(d.name(), "commit", cid) |
| 146 | p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split") | 145 | p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split") | ||
| 147 | 146 | ||||
| 148 | l := s.repoLayout(ctx, d, "commits") | 147 | l := s.repoLayout(ctx, d, "commits") | ||
| 149 | l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance | 148 | l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance | ||
| 150 | - | pinRef(&l, target{rev: c.GetId(), name: p.Short, detached: true}) | 149 | + | pinRef(&l, target{rev: cid, name: p.Short, detached: true}) |
| 151 | history := repoHref(d.name()) | 150 | history := repoHref(d.name()) | ||
| 152 | if d.head != nil { | 151 | if d.head != nil { | ||
| 153 | - | history = repoHref(d.name(), "commits", d.head.GetShortName()) | 152 | + | history = repoHref(d.name(), "commits", d.head.ShortName) |
| 154 | } | 153 | } | ||
| 155 | l.Crumbs = []Crumb{ | 154 | l.Crumbs = []Crumb{ | ||
| 156 | {Label: d.name(), Href: repoHref(d.name())}, | 155 | {Label: d.name(), Href: repoHref(d.name())}, | ||
| @@ -162,25 +161,25 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 162 | } | 161 | } | ||
| 163 | 162 | ||||
| 164 | // person converts a signature. | 163 | // person converts a signature. | ||
| 165 | - | func person(s *pb.Signature, now time.Time) Person { | 164 | + | func person(s domain.Signature, now time.Time) Person { |
| 166 | - | p := Person{Name: visibleText(s.GetName())} | 165 | + | p := Person{Name: visibleText(s.Name)} |
| 167 | - | p.When, p.WhenAt = whenOf(now, s.GetTime()) | 166 | + | p.When, p.WhenAt = whenOf(now, s.Time) |
| 168 | - | if t := s.GetTime(); t.IsValid() { | 167 | + | if !s.Time.IsZero() { |
| 169 | - | p.Abs = absolute(t.AsTime()) | 168 | + | p.Abs = absolute(s.Time) |
| 170 | } | 169 | } | ||
| 171 | return p | 170 | return p | ||
| 172 | } | 171 | } | ||
| 173 | 172 | ||||
| 174 | // commitBody is the message without its first line, for display. | 173 | // commitBody is the message without its first line, for display. | ||
| 175 | - | func commitBody(c *pb.Commit) string { | 174 | + | func commitBody(c domain.Commit) string { |
| 176 | - | msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n") | 175 | + | msg := strings.ReplaceAll(c.Message, "\r\n", "\n") |
| 177 | _, body, _ := strings.Cut(msg, "\n") | 176 | _, body, _ := strings.Cut(msg, "\n") | ||
| 178 | return messageText(strings.TrimSpace(body)) | 177 | return messageText(strings.TrimSpace(body)) | ||
| 179 | } | 178 | } | ||
| 180 | 179 | ||||
| 181 | // diffNote says what is missing from an incomplete diff, "" for a complete one. | 180 | // diffNote says what is missing from an incomplete diff, "" for a complete one. | ||
| 182 | - | func diffNote(d *pb.Diff, shown, total int) string { | 181 | + | func diffNote(d domain.Diff, shown, total int) string { |
| 183 | - | if !d.GetTruncated() { | 182 | + | if !d.Truncated { |
| 184 | return "" | 183 | return "" | ||
| 185 | } | 184 | } | ||
| 186 | var parts []string | 185 | var parts []string | ||
| @@ -188,8 +187,8 @@ func diffNote(d *pb.Diff, shown, total int) string { | |||||
| 188 | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | 187 | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | ||
| 189 | } | 188 | } | ||
| 190 | cut := 0 | 189 | cut := 0 | ||
| 191 | - | for _, f := range d.GetFiles() { | 190 | + | for _, f := range d.Files { |
| 192 | - | if f.GetTruncated() { | 191 | + | if f.Truncated { |
| 193 | cut++ | 192 | cut++ | ||
| 194 | } | 193 | } | ||
| 195 | } | 194 | } | ||
M internal/adapters/web/commits.go +13 -16
| @@ -8,10 +8,9 @@ import ( | |||||
| 8 | "net/url" | 8 | "net/url" | ||
| 9 | "path" | 9 | "path" | ||
| 10 | 10 | ||||
| 11 | - | "connectrpc.com/connect" | |||
| 12 | "github.com/a-h/templ" | 11 | "github.com/a-h/templ" | ||
| 13 | 12 | ||||
| 14 | - | pb "bnema.dev/ermite-api/gen/ermite" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| 17 | // commitsPerPage is how many commits one page of history lists. | 16 | // commitsPerPage is how many commits one page of history lists. | ||
| @@ -44,7 +43,7 @@ type CommitsPage struct { | |||||
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| 46 | // commits lists the history of a ref, newest first, a page at a time, with | 45 | // commits lists the history of a ref, newest first, a page at a time, with | ||
| 47 | - | // an optional path to limit it to. The page token of the API travels in the | 46 | + | // an optional path to limit it to. The page token of the use case travels in the |
| 48 | // "page" query parameter. | 47 | // "page" query parameter. | ||
| 49 | func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 48 | func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 50 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 49 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| @@ -56,13 +55,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 56 | return nil, 0, errNotFound | 55 | return nil, 0, errNotFound | ||
| 57 | } | 56 | } | ||
| 58 | token := r.URL.Query().Get("page") | 57 | token := r.URL.Query().Get("page") | ||
| 59 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 58 | + | res, err := s.commitList(ctx, d.repo.Name, sel.rev, sel.path, commitsPerPage, token) |
| 60 | - | Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token, | |||
| 61 | - | })) | |||
| 62 | if err != nil { | 59 | if err != nil { | ||
| 63 | return nil, 0, err | 60 | return nil, 0, err | ||
| 64 | } | 61 | } | ||
| 65 | - | cs := res.Msg.GetCommits() | 62 | + | cs := res.Commits |
| 66 | if len(cs) == 0 && token == "" && sel.path != "" { | 63 | if len(cs) == 0 && token == "" && sel.path != "" { | ||
| 67 | // Git answers an empty history for a path that never existed. | 64 | // Git answers an empty history for a path that never existed. | ||
| 68 | return nil, 0, errNotFound | 65 | return nil, 0, errNotFound | ||
| @@ -70,7 +67,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 70 | 67 | ||||
| 71 | p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)} | 68 | p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)} | ||
| 72 | base := repoHref(d.name(), "commits", sel.name, sel.path) | 69 | base := repoHref(d.name(), "commits", sel.name, sel.path) | ||
| 73 | - | if t := res.Msg.GetNextPageToken(); t != "" { | 70 | + | if t := res.NextPageToken; t != "" { |
| 74 | p.Older = base + "?page=" + url.QueryEscape(t) | 71 | p.Older = base + "?page=" + url.QueryEscape(t) | ||
| 75 | } | 72 | } | ||
| 76 | if token != "" { | 73 | if token != "" { | ||
| @@ -99,16 +96,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 99 | } | 96 | } | ||
| 100 | 97 | ||||
| 101 | // fillDays groups the commits of a page by day and lists the refs among them. | 98 | // fillDays groups the commits of a page by day and lists the refs among them. | ||
| 102 | - | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) { | 99 | + | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) { |
| 103 | now := s.cfg.Now() | 100 | now := s.cfg.Now() | ||
| 104 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | 101 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | ||
| 105 | for i, c := range cs { | 102 | for i, c := range cs { | ||
| 106 | row := commitRow(d.name(), c, now) | 103 | row := commitRow(d.name(), c, now) | ||
| 107 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | 104 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | ||
| 108 | - | row.Branches, row.Tags = branchesAt[c.GetId()], tagsAt[c.GetId()] | 105 | + | row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()] |
| 109 | day := "unknown date" | 106 | day := "unknown date" | ||
| 110 | - | if t := c.GetCommitter().GetTime(); t.IsValid() { | 107 | + | if t := c.Committer.Time; !t.IsZero() { |
| 111 | - | day = t.AsTime().UTC().Format("2006-01-02") | 108 | + | day = t.UTC().Format("2006-01-02") |
| 112 | } | 109 | } | ||
| 113 | if n := len(p.Days); n == 0 || p.Days[n-1].Day != day { | 110 | if n := len(p.Days); n == 0 || p.Days[n-1].Day != day { | ||
| 114 | p.Days = append(p.Days, DayGroup{Day: day}) | 111 | p.Days = append(p.Days, DayGroup{Day: day}) | ||
| @@ -132,15 +129,15 @@ func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link { | |||||
| 132 | if dir == "." { | 129 | if dir == "." { | ||
| 133 | dir = "" | 130 | dir = "" | ||
| 134 | } | 131 | } | ||
| 135 | - | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir})) | 132 | + | tree, err := s.getTree(ctx, d.repo.Name, sel.rev, dir, false) |
| 136 | if err != nil { | 133 | if err != nil { | ||
| 137 | return nil | 134 | return nil | ||
| 138 | } | 135 | } | ||
| 139 | - | for _, e := range tree.Msg.GetEntries() { | 136 | + | for _, e := range tree.Entries { |
| 140 | - | if e.GetPath() != sel.path { | 137 | + | if e.Path != sel.path { |
| 141 | continue | 138 | continue | ||
| 142 | } | 139 | } | ||
| 143 | - | if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY { | 140 | + | if e.Kind == domain.EntryDirectory { |
| 144 | return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)} | 141 | return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)} | ||
| 145 | } | 142 | } | ||
| 146 | return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)} | 143 | return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)} | ||
M internal/adapters/web/commits.templ +4 -5
| @@ -11,19 +11,18 @@ templ Commits(l Layout, p CommitsPage) { | |||||
| 11 | <input | 11 | <input | ||
| 12 | id="commit-filter" | 12 | id="commit-filter" | ||
| 13 | type="search" | 13 | type="search" | ||
| 14 | - | data-filter | 14 | + | data-filter=".days tr" |
| 15 | + | data-groups=".days section" | |||
| 15 | placeholder="Filter by subject, sha or author…" | 16 | placeholder="Filter by subject, sha or author…" | ||
| 16 | autocomplete="off" | 17 | autocomplete="off" | ||
| 17 | - | hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 18 | + | hx-on:input="ermite.filter(this)" |
| 18 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |||
| 19 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')" | |||
| 20 | /> | 19 | /> | ||
| 21 | </div> | 20 | </div> | ||
| 22 | if p.Path != "" { | 21 | if p.Path != "" { | ||
| 23 | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | 22 | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | ||
| 24 | } | 23 | } | ||
| 25 | if p.Live { | 24 | if p.Live { | ||
| 26 | - | <div id="commits-head"></div> | 25 | + | @LiveSlot("commits-head") |
| 27 | } | 26 | } | ||
| 28 | <div class="days"> | 27 | <div class="days"> | ||
| 29 | for _, g := range p.Days { | 28 | for _, g := range p.Days { | ||
M internal/adapters/web/commits_templ.go +47 -47 generated file: collapsed
M internal/adapters/web/commits_test.go +21 -12
| @@ -4,17 +4,19 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | - | "net/http" | |||
| 8 | "testing" | 7 | "testing" | ||
| 9 | 8 | ||||
| 10 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 14 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 14 | + | "bnema.dev/ermite/internal/ports" |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | func TestIsCommitID(t *testing.T) { | 18 | func TestIsCommitID(t *testing.T) { | ||
| 19 | + | t.Parallel() | |||
| 18 | for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} { | 20 | for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} { | ||
| 19 | assert.True(t, isCommitID(s), s) | 21 | assert.True(t, isCommitID(s), s) | ||
| 20 | } | 22 | } | ||
| @@ -26,15 +28,22 @@ func TestIsCommitID(t *testing.T) { | |||||
| 26 | // The ref switcher marks the ref the page shows, whatever the page: a tag, a | 28 | // The ref switcher marks the ref the page shows, whatever the page: a tag, a | ||
| 27 | // branch other than the default one, or none for a commit. | 29 | // branch other than the default one, or none for a commit. | ||
| 28 | func TestPinRefMarksCurrent(t *testing.T) { | 30 | func TestPinRefMarksCurrent(t *testing.T) { | ||
| 29 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | 31 | + | t.Parallel() |
| 32 | + | b, tag := domain.RefKindBranch, domain.RefKindTag | |||
| 33 | + | name, err := domain.ParseRepoName("demo") | |||
| 34 | + | require.NoError(t, err) | |||
| 30 | d := &repoData{ | 35 | d := &repoData{ | ||
| 31 | - | repo: &pb.Repo{Name: "demo", DefaultBranch: "main"}, | 36 | + | repo: ports.RepoView{Repo: domain.Repo{Name: name}, DefaultBranch: "main"}, |
| 32 | - | branches: []*pb.Ref{ref(b, "main"), ref(b, "side")}, | 37 | + | branches: []domain.Ref{ref(b, "main"), ref(b, "side")}, |
| 33 | - | tags: []*pb.Ref{ref(tag, "v1")}, | 38 | + | tags: []domain.Ref{ref(tag, "v1")}, |
| 34 | } | 39 | } | ||
| 35 | - | d.head = d.branches[0] | 40 | + | d.head = &d.branches[0] |
| 41 | + | // The list of the other repositories is not reachable: the layout does | |||
| 42 | + | // without it. | |||
| 43 | + | repos := mocks.NewRepoService(t) | |||
| 44 | + | repos.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, errors.New("no list")) | |||
| 36 | s, err := New(Config{ | 45 | s, err := New(Config{ | ||
| 37 | - | Repos: failing{err: errors.New("no list")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 46 | + | Repos: repos, Browse: mocks.NewBrowseService(t), |
| 38 | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | 47 | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | ||
| 39 | }) | 48 | }) | ||
| 40 | require.NoError(t, err) | 49 | require.NoError(t, err) | ||
| @@ -51,9 +60,9 @@ func TestPinRefMarksCurrent(t *testing.T) { | |||||
| 51 | heads, tags []string | 60 | heads, tags []string | ||
| 52 | ref, short string | 61 | ref, short string | ||
| 53 | }{ | 62 | }{ | ||
| 54 | - | {"", []string{"main"}, nil, "main", "c-main"}, | 63 | + | {"", []string{"main"}, nil, "main", short(commitOf("main").String())}, |
| 55 | - | {"v1", nil, []string{"v1"}, "v1", "c-v1"}, | 64 | + | {"v1", nil, []string{"v1"}, "v1", short(commitOf("v1").String())}, |
| 56 | - | {"side", []string{"side"}, nil, "side", "c-side"}, | 65 | + | {"side", []string{"side"}, nil, "side", short(commitOf("side").String())}, |
| 57 | {"abcdef0123", nil, nil, "detached", "abcdef0"}, | 66 | {"abcdef0123", nil, nil, "detached", "abcdef0"}, | ||
| 58 | } { | 67 | } { | ||
| 59 | tg, ok := d.locate(tt.rest) | 68 | tg, ok := d.locate(tt.rest) | ||
M internal/adapters/web/compare.go +43 -43
| @@ -7,10 +7,10 @@ import ( | |||||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | "slices" | 8 | "slices" | ||
| 9 | 9 | ||||
| 10 | - | "connectrpc.com/connect" | |||
| 11 | "github.com/a-h/templ" | 10 | "github.com/a-h/templ" | ||
| 12 | 11 | ||||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | 12 | + | "bnema.dev/ermite/internal/domain" |
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // compareDepth bounds the commits read on each side to find where a branch | 16 | // compareDepth bounds the commits read on each side to find where a branch | ||
| @@ -27,6 +27,8 @@ const compareDepth = maxComparePages * pageSize | |||||
| 27 | // own diff is then the whole change). Otherwise it lists the commits, which | 27 | // own diff is then the whole change). Otherwise it lists the commits, which | ||
| 28 | // link to their own diffs, and says the whole diff comes with the merge | 28 | // link to their own diffs, and says the whole diff comes with the merge | ||
| 29 | // request. | 29 | // request. | ||
| 30 | + | // | |||
| 31 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the compare page from the use case results; the merge rules are the use case's | |||
| 30 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 32 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 31 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 33 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 32 | if err != nil { | 34 | if err != nil { | ||
| @@ -36,25 +38,25 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 36 | if !ok || rest != "" || d.head == nil { | 38 | if !ok || rest != "" || d.head == nil { | ||
| 37 | return nil, 0, errNotFound | 39 | return nil, 0, errNotFound | ||
| 38 | } | 40 | } | ||
| 39 | - | tgt := d.head | 41 | + | tgt := *d.head |
| 40 | name := d.name() | 42 | name := d.name() | ||
| 41 | 43 | ||||
| 42 | p := ComparePage{ | 44 | p := ComparePage{ | ||
| 43 | - | Source: visibleText(src.GetShortName()), Target: visibleText(tgt.GetShortName()), | 45 | + | Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName), |
| 44 | - | SourceHref: repoHref(name, "tree", src.GetShortName()), TargetHref: repoHref(name, "tree", tgt.GetShortName()), | 46 | + | SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName), |
| 45 | CLI: Clone{ID: "cli-create", Lines: []string{ | 47 | CLI: Clone{ID: "cli-create", Lines: []string{ | ||
| 46 | - | "ermite mr create " + shellWord(name) + " --source " + shellWord(src.GetShortName()) + " --title <title>", | 48 | + | "ermite mr create " + shellWord(name) + " --source " + shellWord(src.ShortName) + " --title <title>", |
| 47 | }}, | 49 | }}, | ||
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| 50 | - | same := src.GetShortName() == tgt.GetShortName() | 52 | + | same := src.ShortName == tgt.ShortName |
| 51 | - | var ahead []*pb.Commit | 53 | + | var ahead []domain.Commit |
| 52 | exact, behind := false, false | 54 | exact, behind := false, false | ||
| 53 | if same { | 55 | if same { | ||
| 54 | p.Note = "this is the default branch: there is nothing to compare it with." | 56 | p.Note = "this is the default branch: there is nothing to compare it with." | ||
| 55 | p.AheadText = "0 commits" | 57 | p.AheadText = "0 commits" | ||
| 56 | } else { | 58 | } else { | ||
| 57 | - | ahead, exact, behind, err = s.commitsAhead(ctx, name, src.GetCommitId(), tgt.GetCommitId()) | 59 | + | ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String()) |
| 58 | if err != nil { | 60 | if err != nil { | ||
| 59 | return nil, 0, err | 61 | return nil, 0, err | ||
| 60 | } | 62 | } | ||
| @@ -79,19 +81,19 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 79 | 81 | ||||
| 80 | // The exact diff: one commit on top of the tip of the target. | 82 | // The exact diff: one commit on top of the tip of the target. | ||
| 81 | if exact && !behind && len(ahead) == 1 { | 83 | if exact && !behind && len(ahead) == 1 { | ||
| 82 | - | res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: name, Revision: ahead[0].GetId()})) | 84 | + | res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String()) |
| 83 | if err != nil { | 85 | if err != nil { | ||
| 84 | return nil, 0, err | 86 | return nil, 0, err | ||
| 85 | } | 87 | } | ||
| 86 | p.HasDiff = true | 88 | p.HasDiff = true | ||
| 87 | - | p.Files = diffFiles(res.Msg.GetDiff().GetFiles()) | 89 | + | p.Files = diffFiles(res.Diff.Files) |
| 88 | - | p.NFiles, p.Adds, p.Dels = int(res.Msg.GetStats().GetFilesChanged()), int(res.Msg.GetStats().GetAdditions()), int(res.Msg.GetStats().GetDeletions()) | 90 | + | p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions |
| 89 | - | if n := diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles); n != "" { | 91 | + | if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" { |
| 90 | p.Note = n | 92 | p.Note = n | ||
| 91 | } | 93 | } | ||
| 92 | lines, invisible := diffLines(p.Files) | 94 | lines, invisible := diffLines(p.Files) | ||
| 93 | p.Invisible = invisible | 95 | p.Invisible = invisible | ||
| 94 | - | self := repoHref(name, "compare", src.GetShortName()) | 96 | + | self := repoHref(name, "compare", src.ShortName) |
| 95 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | 97 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | ||
| 96 | } else if len(ahead) > 1 && exact { | 98 | } else if len(ahead) > 1 && exact { | ||
| 97 | if p.Note == "" { | 99 | if p.Note == "" { | ||
| @@ -100,32 +102,32 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 100 | } | 102 | } | ||
| 101 | 103 | ||||
| 102 | // An open request for the same pair, or the form to open one. | 104 | // An open request for the same pair, or the form to open one. | ||
| 103 | - | var existing *pb.MergeRequest | 105 | + | var existing *domain.MergeRequest |
| 104 | if !same { | 106 | if !same { | ||
| 105 | - | existing, err = s.openRequestFor(ctx, name, src.GetShortName(), tgt.GetShortName()) | 107 | + | existing, err = s.openRequestFor(ctx, d.repo.Name, src.ShortName, tgt.ShortName) |
| 106 | if err != nil { | 108 | if err != nil { | ||
| 107 | return nil, 0, err | 109 | return nil, 0, err | ||
| 108 | } | 110 | } | ||
| 109 | } | 111 | } | ||
| 110 | switch { | 112 | switch { | ||
| 111 | case existing != nil: | 113 | case existing != nil: | ||
| 112 | - | p.Existing = &Link{Label: "!" + itoa(int(existing.GetNumber())) + " " + visibleText(existing.GetTitle()), Href: mrHref(name, existing.GetNumber())} | 114 | + | p.Existing = &Link{Label: "!" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)} |
| 113 | case SignedIn(ctx) && !same && len(ahead) > 0: | 115 | case SignedIn(ctx) && !same && len(ahead) > 0: | ||
| 114 | title := "" | 116 | title := "" | ||
| 115 | if len(ahead) == 1 { | 117 | if len(ahead) == 1 { | ||
| 116 | - | title = ahead[0].GetSummary() | 118 | + | title = ahead[0].Summary |
| 117 | } else { | 119 | } else { | ||
| 118 | - | title = ahead[len(ahead)-1].GetSummary() | 120 | + | title = ahead[len(ahead)-1].Summary |
| 119 | } | 121 | } | ||
| 120 | p.Create = &CreateForm{ | 122 | p.Create = &CreateForm{ | ||
| 121 | - | Action: mrBase(name), Source: src.GetShortName(), Target: tgt.GetShortName(), | 123 | + | Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName, |
| 122 | Title: cutRunes(title, 120), | 124 | Title: cutRunes(title, 120), | ||
| 123 | } | 125 | } | ||
| 124 | } | 126 | } | ||
| 125 | 127 | ||||
| 126 | l := s.repoLayout(ctx, d, "merge-requests") | 128 | l := s.repoLayout(ctx, d, "merge-requests") | ||
| 127 | l.Title = "compare " + p.Source + " · " + name + " · " + s.instance | 129 | l.Title = "compare " + p.Source + " · " + name + " · " + s.instance | ||
| 128 | - | pinRef(&l, target{rev: src.GetCommitId(), name: src.GetShortName()}) | 130 | + | pinRef(&l, target{rev: src.CommitID.String(), name: src.ShortName}) |
| 129 | l.Crumbs[0].Current = false | 131 | l.Crumbs[0].Current = false | ||
| 130 | l.Crumbs = append(l.Crumbs, | 132 | l.Crumbs = append(l.Crumbs, | ||
| 131 | Crumb{Label: "merge requests", Href: mrBase(name)}, | 133 | Crumb{Label: "merge requests", Href: mrBase(name)}, | ||
| @@ -140,67 +142,65 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 140 | // first. exact is false when the walk gave up before finding where the source | 142 | // first. exact is false when the walk gave up before finding where the source | ||
| 141 | // meets the target. behind is true when the target has commits the source | 143 | // meets the target. behind is true when the target has commits the source | ||
| 142 | // lacks (the tip of the target is not an ancestor of the source). | 144 | // lacks (the tip of the target is not an ancestor of the source). | ||
| 143 | - | func (s *Server) commitsAhead(ctx context.Context, repo, srcTip, tgtTip string) (ahead []*pb.Commit, exact, behind bool, err error) { | 145 | + | // |
| 146 | + | //hexcheck:ignore suspicious-business-logic-in-adapter walks the history pages the use case returns to find where the branch left its target; the page needs it, the forge decides nothing | |||
| 147 | + | func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) { | |||
| 144 | if srcTip == tgtTip { | 148 | if srcTip == tgtTip { | ||
| 145 | return nil, true, false, nil | 149 | return nil, true, false, nil | ||
| 146 | } | 150 | } | ||
| 147 | onTarget := map[string]bool{} | 151 | onTarget := map[string]bool{} | ||
| 148 | tokenT := "" | 152 | tokenT := "" | ||
| 149 | for len(onTarget) < compareDepth { | 153 | for len(onTarget) < compareDepth { | ||
| 150 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 154 | + | res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT) |
| 151 | - | Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT, | |||
| 152 | - | })) | |||
| 153 | if err != nil { | 155 | if err != nil { | ||
| 154 | return nil, false, false, err | 156 | return nil, false, false, err | ||
| 155 | } | 157 | } | ||
| 156 | - | for _, c := range res.Msg.GetCommits() { | 158 | + | for _, c := range res.Commits { |
| 157 | - | onTarget[c.GetId()] = true | 159 | + | onTarget[c.ID.String()] = true |
| 158 | } | 160 | } | ||
| 159 | - | if tokenT = res.Msg.GetNextPageToken(); tokenT == "" { | 161 | + | if tokenT = res.NextPageToken; tokenT == "" { |
| 160 | break | 162 | break | ||
| 161 | } | 163 | } | ||
| 162 | } | 164 | } | ||
| 163 | token := "" | 165 | token := "" | ||
| 164 | for n := 0; n < compareDepth; { | 166 | for n := 0; n < compareDepth; { | ||
| 165 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 167 | + | res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token) |
| 166 | - | Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token, | |||
| 167 | - | })) | |||
| 168 | if err != nil { | 168 | if err != nil { | ||
| 169 | return nil, false, false, err | 169 | return nil, false, false, err | ||
| 170 | } | 170 | } | ||
| 171 | - | for _, c := range res.Msg.GetCommits() { | 171 | + | for _, c := range res.Commits { |
| 172 | n++ | 172 | n++ | ||
| 173 | - | if onTarget[c.GetId()] { | 173 | + | if onTarget[c.ID.String()] { |
| 174 | // The first commit of the source that the target has: where the | 174 | // The first commit of the source that the target has: where the | ||
| 175 | // branch left it. The target moved on when this is not its tip. | 175 | // branch left it. The target moved on when this is not its tip. | ||
| 176 | - | return ahead, true, c.GetId() != tgtTip, nil | 176 | + | return ahead, true, c.ID.String() != tgtTip, nil |
| 177 | } | 177 | } | ||
| 178 | ahead = append(ahead, c) | 178 | ahead = append(ahead, c) | ||
| 179 | } | 179 | } | ||
| 180 | - | if token = res.Msg.GetNextPageToken(); token == "" { | 180 | + | if token = res.NextPageToken; token == "" { |
| 181 | // The whole history of the source is ahead: no common history. | 181 | // The whole history of the source is ahead: no common history. | ||
| 182 | return ahead, true, true, nil | 182 | return ahead, true, true, nil | ||
| 183 | } | 183 | } | ||
| 184 | } | 184 | } | ||
| 185 | - | return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil | 185 | + | return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil |
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | // openRequestFor finds the open merge request with this source and target. | 188 | // openRequestFor finds the open merge request with this source and target. | ||
| 189 | - | func (s *Server) openRequestFor(ctx context.Context, repo, source, target string) (*pb.MergeRequest, error) { | 189 | + | func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) { |
| 190 | token := "" | 190 | token := "" | ||
| 191 | for range maxOpenPages { | 191 | for range maxOpenPages { | ||
| 192 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | 192 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{ |
| 193 | - | Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token, | 193 | + | State: domain.MRStateOpen, PageSize: pageSize, PageToken: token, |
| 194 | - | })) | 194 | + | }) |
| 195 | if err != nil { | 195 | if err != nil { | ||
| 196 | return nil, err | 196 | return nil, err | ||
| 197 | } | 197 | } | ||
| 198 | - | for _, m := range res.Msg.GetMergeRequests() { | 198 | + | for i := range res.MergeRequests { |
| 199 | - | if m.GetSourceBranch() == source && m.GetTargetBranch() == target { | 199 | + | if m := &res.MergeRequests[i]; m.Source == source && m.Target == target { |
| 200 | return m, nil | 200 | return m, nil | ||
| 201 | } | 201 | } | ||
| 202 | } | 202 | } | ||
| 203 | - | if token = res.Msg.GetNextPageToken(); token == "" { | 203 | + | if token = res.NextPageToken; token == "" { |
| 204 | break | 204 | break | ||
| 205 | } | 205 | } | ||
| 206 | } | 206 | } | ||
M internal/adapters/web/components.templ +3 -2
| @@ -22,9 +22,10 @@ templ CloneBlock(c Clone) { | |||||
| 22 | <button | 22 | <button | ||
| 23 | type="button" | 23 | type="button" | ||
| 24 | class="btn copy" | 24 | class="btn copy" | ||
| 25 | + | data-js | |||
| 25 | data-label="Copy" | 26 | data-label="Copy" | ||
| 26 | aria-label={ "copy: " + line } | 27 | aria-label={ "copy: " + line } | ||
| 27 | - | hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label" | 28 | + | hx-on:click="ermite.copy(this)" |
| 28 | >Copy</button> | 29 | >Copy</button> | ||
| 29 | </div> | 30 | </div> | ||
| 30 | } | 31 | } | ||
| @@ -76,7 +77,7 @@ templ ReadmeCard(r Readme) { | |||||
| 76 | // ReadmeView is a rendered README. Its HTML comes from the Markdown | 77 | // ReadmeView is a rendered README. Its HTML comes from the Markdown | ||
| 77 | // renderer, which drops raw HTML and filters URLs. | 78 | // renderer, which drops raw HTML and filters URLs. | ||
| 78 | templ ReadmeView(r Readme) { | 79 | templ ReadmeView(r Readme) { | ||
| 79 | - | <article class="prose" aria-label={ r.Name }> | 80 | + | <article class="prose" aria-label={ r.Name } hx-ignore> |
| 80 | if r.Note != "" { | 81 | if r.Note != "" { | ||
| 81 | <p class="dim">{ r.Note }</p> | 82 | <p class="dim">{ r.Note }</p> | ||
| 82 | } else { | 83 | } else { | ||
M internal/adapters/web/components_templ.go +26 -26 generated file: collapsed
A internal/adapters/web/compress.go +182 -0
| @@ -0,0 +1,182 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "compress/gzip" | |||
| 8 | + | "crypto/sha256" | |||
| 9 | + | "encoding/hex" | |||
| 10 | + | "io" | |||
| 11 | + | "net/http" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // minCompress is the size under which a page is sent as it is: the gzip | |||
| 18 | + | // header and the CPU cost are not worth it for a few hundred bytes. | |||
| 19 | + | const minCompress = 1024 | |||
| 20 | + | ||||
| 21 | + | // gzipLevel is the level of the pages, compressed on each request: a good | |||
| 22 | + | // ratio at a cost small enough for a render that already takes longer. The | |||
| 23 | + | // assets, compressed once at start, use the best level. | |||
| 24 | + | const gzipLevel = 5 | |||
| 25 | + | ||||
| 26 | + | // gzipWriters recycles the writers of the pages: a gzip.Writer holds about | |||
| 27 | + | // 800 KiB of state. | |||
| 28 | + | var gzipWriters = sync.Pool{New: func() any { | |||
| 29 | + | zw, _ := gzip.NewWriterLevel(nil, gzipLevel) // the level is valid | |||
| 30 | + | return zw | |||
| 31 | + | }} | |||
| 32 | + | ||||
| 33 | + | // gzipPage compresses b with a pooled writer. | |||
| 34 | + | func gzipPage(b []byte) ([]byte, error) { | |||
| 35 | + | var out bytes.Buffer | |||
| 36 | + | zw := gzipWriters.Get().(*gzip.Writer) | |||
| 37 | + | defer func() { | |||
| 38 | + | // Drop the reference to out, so a pooled writer does not keep the | |||
| 39 | + | // last page alive. | |||
| 40 | + | zw.Reset(io.Discard) | |||
| 41 | + | gzipWriters.Put(zw) | |||
| 42 | + | }() | |||
| 43 | + | zw.Reset(&out) | |||
| 44 | + | if _, err := zw.Write(b); err != nil { | |||
| 45 | + | return nil, err | |||
| 46 | + | } | |||
| 47 | + | if err := zw.Close(); err != nil { | |||
| 48 | + | return nil, err | |||
| 49 | + | } | |||
| 50 | + | return out.Bytes(), nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // gzipBest compresses b once, at the best level (for the assets). | |||
| 54 | + | func gzipBest(b []byte) ([]byte, error) { | |||
| 55 | + | var out bytes.Buffer | |||
| 56 | + | zw, err := gzip.NewWriterLevel(&out, gzip.BestCompression) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return nil, err | |||
| 59 | + | } | |||
| 60 | + | if _, err := zw.Write(b); err != nil { | |||
| 61 | + | return nil, err | |||
| 62 | + | } | |||
| 63 | + | if err := zw.Close(); err != nil { | |||
| 64 | + | return nil, err | |||
| 65 | + | } | |||
| 66 | + | return out.Bytes(), nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // acceptsGzip reports whether the Accept-Encoding of a request allows gzip | |||
| 70 | + | // (RFC 9110 §12.5.3): gzip, or its alias x-gzip, with a quality above 0, or | |||
| 71 | + | // "*" when gzip is not named. A quality of 0 refuses the coding; a malformed | |||
| 72 | + | // quality is a refusal as well. | |||
| 73 | + | func acceptsGzip(h http.Header) bool { | |||
| 74 | + | star, starSet := false, false | |||
| 75 | + | for _, line := range h.Values("Accept-Encoding") { | |||
| 76 | + | for part := range strings.SplitSeq(line, ",") { | |||
| 77 | + | coding, params, _ := strings.Cut(part, ";") | |||
| 78 | + | coding = strings.ToLower(strings.TrimSpace(coding)) | |||
| 79 | + | if coding != "gzip" && coding != "x-gzip" && coding != "*" { | |||
| 80 | + | continue | |||
| 81 | + | } | |||
| 82 | + | ok := qualityPositive(params) | |||
| 83 | + | switch coding { | |||
| 84 | + | case "*": | |||
| 85 | + | star, starSet = ok, true | |||
| 86 | + | default: | |||
| 87 | + | return ok | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | return starSet && star | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // qualityPositive reads the q parameter of a coding (1 when absent) and | |||
| 95 | + | // reports whether it is above 0. | |||
| 96 | + | func qualityPositive(params string) bool { | |||
| 97 | + | for p := range strings.SplitSeq(params, ";") { | |||
| 98 | + | k, v, found := strings.Cut(p, "=") | |||
| 99 | + | if !found || !strings.EqualFold(strings.TrimSpace(k), "q") { | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | q, err := strconv.ParseFloat(strings.TrimSpace(v), 64) | |||
| 103 | + | return err == nil && q > 0 | |||
| 104 | + | } | |||
| 105 | + | return true | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // addVary adds a field to Vary unless it is already there (or Vary is *). | |||
| 109 | + | func addVary(h http.Header, field string) { | |||
| 110 | + | for _, v := range h.Values("Vary") { | |||
| 111 | + | for f := range strings.SplitSeq(v, ",") { | |||
| 112 | + | if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, field) { | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | h.Add("Vary", field) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // noTransform tells whether Cache-Control forbids changing the body. | |||
| 121 | + | func noTransform(h http.Header) bool { | |||
| 122 | + | return hasCacheDirective(h, "no-transform") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // hasCacheDirective tells whether Cache-Control holds the directive. | |||
| 126 | + | func hasCacheDirective(h http.Header, name string) bool { | |||
| 127 | + | for _, v := range h.Values("Cache-Control") { | |||
| 128 | + | for d := range strings.SplitSeq(v, ",") { | |||
| 129 | + | if strings.EqualFold(strings.TrimSpace(d), name) { | |||
| 130 | + | return true | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return false | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // pageETag is the strong validator of a rendered page: a hash of its | |||
| 138 | + | // identity bytes and of the session state, so that the page of the owner | |||
| 139 | + | // never shares a tag with the page of a visitor. The gzip representation | |||
| 140 | + | // has a tag of its own (RFC 9110 §8.8.3). | |||
| 141 | + | func pageETag(body []byte, signedIn, gzipped bool) string { | |||
| 142 | + | h := sha256.New() | |||
| 143 | + | if signedIn { | |||
| 144 | + | h.Write([]byte{1}) | |||
| 145 | + | } else { | |||
| 146 | + | h.Write([]byte{0}) | |||
| 147 | + | } | |||
| 148 | + | h.Write(body) | |||
| 149 | + | tag := hex.EncodeToString(h.Sum(nil)[:16]) | |||
| 150 | + | if gzipped { | |||
| 151 | + | tag += "-gz" | |||
| 152 | + | } | |||
| 153 | + | return `"` + tag + `"` | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // etagMatches implements the weak comparison of If-None-Match (RFC 9110 | |||
| 157 | + | // §13.1.2): a list of entity tags, each optionally weak, or "*". | |||
| 158 | + | func etagMatches(header, etag string) bool { | |||
| 159 | + | want := strings.TrimPrefix(etag, "W/") | |||
| 160 | + | s := header | |||
| 161 | + | for { | |||
| 162 | + | s = strings.TrimLeft(s, " \t,") | |||
| 163 | + | if s == "" { | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | if s[0] == '*' { | |||
| 167 | + | return true | |||
| 168 | + | } | |||
| 169 | + | s = strings.TrimPrefix(s, "W/") | |||
| 170 | + | if s == "" || s[0] != '"' { | |||
| 171 | + | return false // malformed: nothing after this can be trusted | |||
| 172 | + | } | |||
| 173 | + | end := strings.IndexByte(s[1:], '"') | |||
| 174 | + | if end < 0 { | |||
| 175 | + | return false | |||
| 176 | + | } | |||
| 177 | + | if s[:end+2] == want { | |||
| 178 | + | return true | |||
| 179 | + | } | |||
| 180 | + | s = s[end+2:] | |||
| 181 | + | } | |||
| 182 | + | } | |||
A internal/adapters/web/compress_test.go +324 -0
| @@ -0,0 +1,324 @@ | |||||
| 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 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/a-h/templ" | |||
| 18 | + | "github.com/stretchr/testify/assert" | |||
| 19 | + | "github.com/stretchr/testify/require" | |||
| 20 | + | ||||
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func TestAcceptsGzip(t *testing.T) { | |||
| 25 | + | t.Parallel() | |||
| 26 | + | for _, tc := range []struct { | |||
| 27 | + | name string | |||
| 28 | + | header []string | |||
| 29 | + | want bool | |||
| 30 | + | }{ | |||
| 31 | + | {"absent", nil, false}, | |||
| 32 | + | {"gzip", []string{"gzip"}, true}, | |||
| 33 | + | {"list", []string{"deflate, gzip, br"}, true}, | |||
| 34 | + | {"case", []string{"GZip"}, true}, | |||
| 35 | + | {"x-gzip alias", []string{"x-gzip"}, true}, | |||
| 36 | + | {"q=1", []string{"gzip;q=1.0"}, true}, | |||
| 37 | + | {"q spaces", []string{"gzip ; q=0.5"}, true}, | |||
| 38 | + | {"q=0 refuses", []string{"gzip;q=0"}, false}, | |||
| 39 | + | {"q=0.0 refuses", []string{"br, gzip;q=0.0"}, false}, | |||
| 40 | + | {"bad q refuses", []string{"gzip;q=abc"}, false}, | |||
| 41 | + | {"identity only", []string{"identity"}, false}, | |||
| 42 | + | {"br only", []string{"br"}, false}, | |||
| 43 | + | {"star", []string{"*"}, true}, | |||
| 44 | + | {"star q=0", []string{"*;q=0"}, false}, | |||
| 45 | + | {"gzip refused beats star", []string{"gzip;q=0, *"}, false}, | |||
| 46 | + | {"gzip accepted beats star q=0", []string{"*;q=0, gzip"}, true}, | |||
| 47 | + | {"several lines", []string{"br", "gzip"}, true}, | |||
| 48 | + | {"substring is not a coding", []string{"gzipped"}, false}, | |||
| 49 | + | } { | |||
| 50 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 51 | + | t.Parallel() | |||
| 52 | + | h := http.Header{} | |||
| 53 | + | for _, v := range tc.header { | |||
| 54 | + | h.Add("Accept-Encoding", v) | |||
| 55 | + | } | |||
| 56 | + | assert.Equal(t, tc.want, acceptsGzip(h)) | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestEtagMatches(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | const tag = `"abc"` | |||
| 64 | + | for _, tc := range []struct { | |||
| 65 | + | name, header, etag string | |||
| 66 | + | want bool | |||
| 67 | + | }{ | |||
| 68 | + | {"empty", "", tag, false}, | |||
| 69 | + | {"same", `"abc"`, tag, true}, | |||
| 70 | + | {"other", `"abd"`, tag, false}, | |||
| 71 | + | {"weak client tag", `W/"abc"`, tag, true}, | |||
| 72 | + | {"list", `"x", "abc", "y"`, tag, true}, | |||
| 73 | + | {"list no space", `"x","abc"`, tag, true}, | |||
| 74 | + | {"list without it", `"x", W/"y"`, tag, false}, | |||
| 75 | + | {"star", `*`, tag, true}, | |||
| 76 | + | {"unquoted", `abc`, tag, false}, | |||
| 77 | + | {"unterminated", `"abc`, tag, false}, | |||
| 78 | + | {"prefix is not equal", `"ab"`, tag, false}, | |||
| 79 | + | {"weak server tag", `"abc"`, `W/"abc"`, true}, | |||
| 80 | + | {"comma in a tag", `"a,b", "abc"`, tag, true}, | |||
| 81 | + | } { | |||
| 82 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 83 | + | t.Parallel() | |||
| 84 | + | assert.Equal(t, tc.want, etagMatches(tc.header, tc.etag)) | |||
| 85 | + | }) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestPageETag(t *testing.T) { | |||
| 90 | + | t.Parallel() | |||
| 91 | + | body := []byte("<html>x</html>") | |||
| 92 | + | a := pageETag(body, false, false) | |||
| 93 | + | assert.Equal(t, a, pageETag(body, false, false), "stable") | |||
| 94 | + | assert.NotEqual(t, a, pageETag(body, true, false), "owner and visitor do not share a tag") | |||
| 95 | + | assert.NotEqual(t, a, pageETag(body, false, true), "gzip is another representation") | |||
| 96 | + | assert.NotEqual(t, a, pageETag([]byte("<html>y</html>"), false, false)) | |||
| 97 | + | assert.True(t, strings.HasPrefix(a, `"`) && strings.HasSuffix(a, `"`)) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func newBareServer(t *testing.T) *Server { | |||
| 101 | + | t.Helper() | |||
| 102 | + | return newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)}) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func gunzip(t *testing.T, b []byte) string { | |||
| 106 | + | t.Helper() | |||
| 107 | + | zr, err := gzip.NewReader(bytes.NewReader(b)) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | out, err := io.ReadAll(zr) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | return string(out) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // write: gzip by size and Accept-Encoding, validators, 304. | |||
| 115 | + | func TestWritePage(t *testing.T) { | |||
| 116 | + | t.Parallel() | |||
| 117 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |||
| 118 | + | small := "<p>tiny</p>" | |||
| 119 | + | ||||
| 120 | + | for _, tc := range []struct { | |||
| 121 | + | name string | |||
| 122 | + | method string | |||
| 123 | + | ae string | |||
| 124 | + | body string | |||
| 125 | + | status int | |||
| 126 | + | cc string // Cache-Control set by the handler beforehand | |||
| 127 | + | wantGzip bool | |||
| 128 | + | wantTag bool | |||
| 129 | + | }{ | |||
| 130 | + | {"big page, gzip", "GET", "gzip", big, 200, "", true, true}, | |||
| 131 | + | {"big page, no Accept-Encoding", "GET", "", big, 200, "", false, true}, | |||
| 132 | + | {"big page, gzip refused", "GET", "gzip;q=0", big, 200, "", false, true}, | |||
| 133 | + | {"big page, br only", "GET", "br", big, 200, "", false, true}, | |||
| 134 | + | {"small page not compressed", "GET", "gzip", small, 200, "", false, true}, | |||
| 135 | + | {"HEAD keeps headers, no body", "HEAD", "gzip", big, 200, "", true, true}, | |||
| 136 | + | {"404 page compressed, no tag", "GET", "gzip", big, 404, "", true, false}, | |||
| 137 | + | {"no-store handler: no tag", "GET", "gzip", big, 200, "no-store", true, false}, | |||
| 138 | + | {"no-transform: untouched", "GET", "gzip", big, 200, "no-transform", false, true}, | |||
| 139 | + | } { | |||
| 140 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 141 | + | t.Parallel() | |||
| 142 | + | srv := newBareServer(t) | |||
| 143 | + | rec := httptest.NewRecorder() | |||
| 144 | + | r := httptest.NewRequestWithContext(context.Background(), tc.method, "/", nil) | |||
| 145 | + | if tc.ae != "" { | |||
| 146 | + | r.Header.Set("Accept-Encoding", tc.ae) | |||
| 147 | + | } | |||
| 148 | + | if tc.cc != "" { | |||
| 149 | + | rec.Header().Set("Cache-Control", tc.cc) | |||
| 150 | + | } | |||
| 151 | + | srv.write(r.Context(), rec, r, tc.status, templ.Raw(tc.body)) | |||
| 152 | + | ||||
| 153 | + | res := rec.Result() | |||
| 154 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |||
| 155 | + | raw, _ := io.ReadAll(res.Body) | |||
| 156 | + | assert.Equal(t, tc.status, res.StatusCode) | |||
| 157 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |||
| 158 | + | assert.Equal(t, tc.wantTag, res.Header.Get("ETag") != "") | |||
| 159 | + | switch { | |||
| 160 | + | case tc.method == "HEAD": | |||
| 161 | + | assert.Empty(t, raw) | |||
| 162 | + | assert.Equal(t, tc.wantGzip, res.Header.Get("Content-Encoding") == "gzip") | |||
| 163 | + | case tc.wantGzip: | |||
| 164 | + | assert.Equal(t, "gzip", res.Header.Get("Content-Encoding")) | |||
| 165 | + | assert.Equal(t, itoa(len(raw)), res.Header.Get("Content-Length")) | |||
| 166 | + | assert.Less(t, len(raw), len(tc.body)) | |||
| 167 | + | assert.Equal(t, tc.body, gunzip(t, raw)) | |||
| 168 | + | default: | |||
| 169 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |||
| 170 | + | assert.Equal(t, tc.body, string(raw)) | |||
| 171 | + | } | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestWritePageConditional(t *testing.T) { | |||
| 177 | + | t.Parallel() | |||
| 178 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |||
| 179 | + | srv := newBareServer(t) | |||
| 180 | + | do := func(ae, inm string) *http.Response { | |||
| 181 | + | rec := httptest.NewRecorder() | |||
| 182 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil) | |||
| 183 | + | if ae != "" { | |||
| 184 | + | r.Header.Set("Accept-Encoding", ae) | |||
| 185 | + | } | |||
| 186 | + | if inm != "" { | |||
| 187 | + | r.Header.Set("If-None-Match", inm) | |||
| 188 | + | } | |||
| 189 | + | srv.write(r.Context(), rec, r, 200, templ.Raw(big)) | |||
| 190 | + | res := rec.Result() | |||
| 191 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |||
| 192 | + | return res | |||
| 193 | + | } | |||
| 194 | + | plain, gz := do("", "").Header.Get("ETag"), do("gzip", "").Header.Get("ETag") | |||
| 195 | + | require.NotEmpty(t, plain) | |||
| 196 | + | require.NotEmpty(t, gz) | |||
| 197 | + | assert.NotEqual(t, plain, gz, "identity and gzip are different representations") | |||
| 198 | + | ||||
| 199 | + | for _, tc := range []struct { | |||
| 200 | + | name, ae, inm string | |||
| 201 | + | want int | |||
| 202 | + | }{ | |||
| 203 | + | {"identity match", "", plain, 304}, | |||
| 204 | + | {"gzip match", "gzip", gz, 304}, | |||
| 205 | + | {"list", "gzip", `"nope", ` + gz, 304}, | |||
| 206 | + | {"weak prefix", "", "W/" + plain, 304}, | |||
| 207 | + | {"star", "", "*", 304}, | |||
| 208 | + | {"identity tag for gzip request", "gzip", plain, 200}, | |||
| 209 | + | {"gzip tag for identity request", "", gz, 200}, | |||
| 210 | + | {"stale", "", `"stale"`, 200}, | |||
| 211 | + | } { | |||
| 212 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 213 | + | t.Parallel() | |||
| 214 | + | res := do(tc.ae, tc.inm) | |||
| 215 | + | assert.Equal(t, tc.want, res.StatusCode) | |||
| 216 | + | b, _ := io.ReadAll(res.Body) | |||
| 217 | + | if tc.want == 304 { | |||
| 218 | + | assert.Empty(t, b) | |||
| 219 | + | assert.NotEmpty(t, res.Header.Get("ETag")) | |||
| 220 | + | assert.Equal(t, "no-cache", res.Header.Get("Cache-Control")) | |||
| 221 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |||
| 222 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |||
| 223 | + | } else { | |||
| 224 | + | assert.NotEmpty(t, b) | |||
| 225 | + | } | |||
| 226 | + | }) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // The owner's page is private and never revalidated, whatever the client sends. | |||
| 231 | + | func TestWritePageOwnerHasNoValidator(t *testing.T) { | |||
| 232 | + | t.Parallel() | |||
| 233 | + | srv := newBareServer(t) | |||
| 234 | + | ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | |||
| 235 | + | rec := httptest.NewRecorder() | |||
| 236 | + | r := httptest.NewRequestWithContext(ctx, "GET", "/", nil) | |||
| 237 | + | r.Header.Set("If-None-Match", "*") | |||
| 238 | + | srv.write(ctx, rec, r, 200, templ.Raw("<p>mine</p>")) | |||
| 239 | + | assert.Equal(t, 200, rec.Code) | |||
| 240 | + | assert.Equal(t, sessionCache, rec.Header().Get("Cache-Control")) | |||
| 241 | + | assert.Empty(t, rec.Header().Get("ETag")) | |||
| 242 | + | assert.Contains(t, rec.Header().Values("Vary"), "Cookie") | |||
| 243 | + | assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // The assets: gzip once at load, chosen by Accept-Encoding, not for fonts. | |||
| 247 | + | func TestAssetsGzip(t *testing.T) { | |||
| 248 | + | t.Parallel() | |||
| 249 | + | srv := newBareServer(t) | |||
| 250 | + | get := func(url, ae string) *httptest.ResponseRecorder { | |||
| 251 | + | rec := httptest.NewRecorder() | |||
| 252 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", url, nil) | |||
| 253 | + | if ae != "" { | |||
| 254 | + | r.Header.Set("Accept-Encoding", ae) | |||
| 255 | + | } | |||
| 256 | + | srv.ServeHTTP(rec, r) | |||
| 257 | + | return rec | |||
| 258 | + | } | |||
| 259 | + | u := srv.assets.urls | |||
| 260 | + | for _, tc := range []struct { | |||
| 261 | + | name, url, ae string | |||
| 262 | + | wantGzip bool | |||
| 263 | + | vary bool | |||
| 264 | + | }{ | |||
| 265 | + | {"css gzip", u.CSS, "gzip, br", true, true}, | |||
| 266 | + | {"css identity", u.CSS, "", false, true}, | |||
| 267 | + | {"css refused", u.CSS, "gzip;q=0", false, true}, | |||
| 268 | + | {"js gzip", u.HTMX, "gzip", true, true}, | |||
| 269 | + | {"rules gzip", u.Rules, "gzip", true, true}, | |||
| 270 | + | {"svg gzip", u.Icon, "gzip", true, true}, | |||
| 271 | + | {"font never", u.Font, "gzip", false, false}, | |||
| 272 | + | } { | |||
| 273 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 274 | + | t.Parallel() | |||
| 275 | + | rec := get(tc.url, tc.ae) | |||
| 276 | + | require.Equal(t, 200, rec.Code) | |||
| 277 | + | assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control")) | |||
| 278 | + | assert.Equal(t, tc.vary, strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "Accept-Encoding")) | |||
| 279 | + | assert.Equal(t, itoa(rec.Body.Len()), rec.Header().Get("Content-Length")) | |||
| 280 | + | plain := srv.assets.byName[strings.TrimPrefix(tc.url, StaticPrefix)].body | |||
| 281 | + | if tc.wantGzip { | |||
| 282 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |||
| 283 | + | assert.Less(t, rec.Body.Len(), len(plain)) | |||
| 284 | + | assert.Equal(t, string(plain), gunzip(t, rec.Body.Bytes())) | |||
| 285 | + | } else { | |||
| 286 | + | assert.Empty(t, rec.Header().Get("Content-Encoding")) | |||
| 287 | + | assert.Equal(t, string(plain), rec.Body.String()) | |||
| 288 | + | } | |||
| 289 | + | }) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | t.Run("HEAD has the length of the encoded body", func(t *testing.T) { | |||
| 293 | + | rec := httptest.NewRecorder() | |||
| 294 | + | r := httptest.NewRequestWithContext(context.Background(), "HEAD", u.CSS, nil) | |||
| 295 | + | r.Header.Set("Accept-Encoding", "gzip") | |||
| 296 | + | srv.ServeHTTP(rec, r) | |||
| 297 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |||
| 298 | + | assert.Equal(t, itoa(len(srv.assets.byName[strings.TrimPrefix(u.CSS, StaticPrefix)].gz)), rec.Header().Get("Content-Length")) | |||
| 299 | + | assert.Zero(t, rec.Body.Len()) | |||
| 300 | + | }) | |||
| 301 | + | ||||
| 302 | + | t.Run("compressible types", func(t *testing.T) { | |||
| 303 | + | for typ, want := range map[string]bool{ | |||
| 304 | + | "text/css; charset=utf-8": true, "text/javascript; charset=utf-8": true, | |||
| 305 | + | "application/speculationrules+json": true, "image/svg+xml": true, | |||
| 306 | + | "font/woff2": false, "image/png": false, | |||
| 307 | + | } { | |||
| 308 | + | assert.Equal(t, want, compressible(typ), typ) | |||
| 309 | + | } | |||
| 310 | + | }) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // The event stream is never compressed, even for a client that accepts gzip. | |||
| 314 | + | func TestEventsNotCompressed(t *testing.T) { | |||
| 315 | + | t.Parallel() | |||
| 316 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 317 | + | ts := eventsServer(t, f, 20*time.Millisecond, 0) | |||
| 318 | + | resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"}) | |||
| 319 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 320 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |||
| 321 | + | assert.Empty(t, resp.Header.Get("Content-Encoding")) | |||
| 322 | + | assert.Empty(t, resp.Header.Get("ETag")) | |||
| 323 | + | assert.Contains(t, resp.Header.Get("Cache-Control"), "no-transform") | |||
| 324 | + | } | |||
M internal/adapters/web/diff.go +50 -50
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/a-h/templ" | 14 | "github.com/a-h/templ" | ||
| 15 | 15 | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | 16 | + | "bnema.dev/ermite/internal/domain" |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // Seg is a piece of a line of code: plain text, or the visible marker of an | 19 | // Seg is a piece of a line of code: plain text, or the visible marker of an | ||
| @@ -101,11 +101,11 @@ type DiffFile struct { | |||||
| 101 | Hunks []DiffHunk | 101 | Hunks []DiffHunk | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | func lineNumber(n int32) string { | 104 | + | func lineNumber(n int) string { |
| 105 | if n <= 0 { | 105 | if n <= 0 { | ||
| 106 | return "" | 106 | return "" | ||
| 107 | } | 107 | } | ||
| 108 | - | return itoa(int(n)) | 108 | + | return itoa(n) |
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | // segments splits a line of code into plain text and markers. One trailing | 111 | // segments splits a line of code into plain text and markers. One trailing | ||
| @@ -197,79 +197,79 @@ func changeBar(add, del, most int) Bar { | |||||
| 197 | } | 197 | } | ||
| 198 | 198 | ||||
| 199 | // statusLetter is the one-letter status of a file. | 199 | // statusLetter is the one-letter status of a file. | ||
| 200 | - | func statusLetter(s pb.FileStatus) string { | 200 | + | func statusLetter(s domain.FileStatus) string { |
| 201 | switch s { | 201 | switch s { | ||
| 202 | - | case pb.FileStatus_FILE_STATUS_ADDED: | 202 | + | case domain.FileAdded: |
| 203 | return "A" | 203 | return "A" | ||
| 204 | - | case pb.FileStatus_FILE_STATUS_DELETED: | 204 | + | case domain.FileDeleted: |
| 205 | return "D" | 205 | return "D" | ||
| 206 | - | case pb.FileStatus_FILE_STATUS_RENAMED: | 206 | + | case domain.FileRenamed: |
| 207 | return "R" | 207 | return "R" | ||
| 208 | - | case pb.FileStatus_FILE_STATUS_COPIED: | 208 | + | case domain.FileCopied: |
| 209 | return "C" | 209 | return "C" | ||
| 210 | - | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | 210 | + | case domain.FileTypeChanged: |
| 211 | return "T" | 211 | return "T" | ||
| 212 | } | 212 | } | ||
| 213 | return "M" | 213 | return "M" | ||
| 214 | } | 214 | } | ||
| 215 | 215 | ||||
| 216 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | 216 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | ||
| 217 | - | func hunkHeader(h *pb.Hunk) string { | 217 | + | func hunkHeader(h domain.Hunk) string { |
| 218 | - | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines()) | 218 | + | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines) |
| 219 | - | if t := h.GetHeader(); t != "" { | 219 | + | if t := h.Header; t != "" { |
| 220 | s += " " + visibleText(strings.TrimSpace(t)) | 220 | s += " " + visibleText(strings.TrimSpace(t)) | ||
| 221 | } | 221 | } | ||
| 222 | return s | 222 | return s | ||
| 223 | } | 223 | } | ||
| 224 | 224 | ||||
| 225 | - | // diffLine converts a line of the API. | 225 | + | // diffLine converts a line of a hunk. |
| 226 | - | func diffLine(l *pb.Line) DiffLine { | 226 | + | func diffLine(l domain.Line) DiffLine { |
| 227 | - | dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())} | 227 | + | dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber} |
| 228 | - | switch l.GetKind() { | 228 | + | switch l.Kind { |
| 229 | - | case pb.LineKind_LINE_KIND_ADDITION: | 229 | + | case domain.LineAddition: |
| 230 | - | dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber()) | 230 | + | dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber) |
| 231 | - | case pb.LineKind_LINE_KIND_DELETION: | 231 | + | case domain.LineDeletion: |
| 232 | - | dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber()) | 232 | + | dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber) |
| 233 | default: | 233 | default: | ||
| 234 | dl.Kind, dl.Mark = "ctx", " " | 234 | dl.Kind, dl.Mark = "ctx", " " | ||
| 235 | - | dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber()) | 235 | + | dl.Old, dl.New = lineNumber(l.OldNumber), lineNumber(l.NewNumber) |
| 236 | } | 236 | } | ||
| 237 | return dl | 237 | return dl | ||
| 238 | } | 238 | } | ||
| 239 | 239 | ||||
| 240 | // diffFiles converts the files of a diff for the page. | 240 | // diffFiles converts the files of a diff for the page. | ||
| 241 | - | func diffFiles(files []*pb.FileDiff) []DiffFile { | 241 | + | func diffFiles(files []domain.FileDiff) []DiffFile { |
| 242 | most := 0 | 242 | most := 0 | ||
| 243 | for _, f := range files { | 243 | for _, f := range files { | ||
| 244 | - | most = max(most, int(f.GetAdditions()+f.GetDeletions())) | 244 | + | most = max(most, f.Additions+f.Deletions) |
| 245 | } | 245 | } | ||
| 246 | out := make([]DiffFile, 0, len(files)) | 246 | out := make([]DiffFile, 0, len(files)) | ||
| 247 | for i, f := range files { | 247 | for i, f := range files { | ||
| 248 | df := DiffFile{ | 248 | df := DiffFile{ | ||
| 249 | - | ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()), | 249 | + | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), |
| 250 | - | Add: int(f.GetAdditions()), Del: int(f.GetDeletions()), | 250 | + | Add: f.Additions, Del: f.Deletions, |
| 251 | - | Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most), | 251 | + | Bar: changeBar(f.Additions, f.Deletions, most), |
| 252 | // A file that is shown in full opens; a file with nothing to read | 252 | // A file that is shown in full opens; a file with nothing to read | ||
| 253 | // (binary, truncated) stays closed under its note. | 253 | // (binary, truncated) stays closed under its note. | ||
| 254 | - | Open: len(f.GetHunks()) > 0 && !f.GetTruncated(), | 254 | + | Open: len(f.Hunks) > 0 && !f.Truncated, |
| 255 | } | 255 | } | ||
| 256 | - | df.Key = f.GetNewPath() | 256 | + | df.Key = f.NewPath |
| 257 | - | switch f.GetStatus() { | 257 | + | switch f.Status { |
| 258 | - | case pb.FileStatus_FILE_STATUS_DELETED: | 258 | + | case domain.FileDeleted: |
| 259 | - | df.Key = f.GetOldPath() | 259 | + | df.Key = f.OldPath |
| 260 | - | df.Path = visibleText(f.GetOldPath()) | 260 | + | df.Path = visibleText(f.OldPath) |
| 261 | - | case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED: | 261 | + | case domain.FileRenamed, domain.FileCopied: |
| 262 | - | df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath()) | 262 | + | df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath) |
| 263 | default: | 263 | default: | ||
| 264 | - | df.Path = visibleText(f.GetNewPath()) | 264 | + | df.Path = visibleText(f.NewPath) |
| 265 | } | 265 | } | ||
| 266 | df.Note = fileNote(f) | 266 | df.Note = fileNote(f) | ||
| 267 | - | if f.GetBinary() { | 267 | + | if f.Binary { |
| 268 | df.Bar = Bar{Rest: "bin"} | 268 | df.Bar = Bar{Rest: "bin"} | ||
| 269 | } | 269 | } | ||
| 270 | - | for _, h := range f.GetHunks() { | 270 | + | for _, h := range f.Hunks { |
| 271 | dh := DiffHunk{Header: hunkHeader(h)} | 271 | dh := DiffHunk{Header: hunkHeader(h)} | ||
| 272 | - | for _, l := range h.GetLines() { | 272 | + | for _, l := range h.Lines { |
| 273 | dh.Lines = append(dh.Lines, diffLine(l)) | 273 | dh.Lines = append(dh.Lines, diffLine(l)) | ||
| 274 | } | 274 | } | ||
| 275 | dh.Split = pairLines(dh.Lines) | 275 | dh.Split = pairLines(dh.Lines) | ||
| @@ -282,23 +282,23 @@ func diffFiles(files []*pb.FileDiff) []DiffFile { | |||||
| 282 | 282 | ||||
| 283 | // fileNote says what the hunks cannot: the kind of change when there is no | 283 | // fileNote says what the hunks cannot: the kind of change when there is no | ||
| 284 | // text to show, the mode change, the similarity of a rename. | 284 | // text to show, the mode change, the similarity of a rename. | ||
| 285 | - | func fileNote(f *pb.FileDiff) string { | 285 | + | func fileNote(f domain.FileDiff) string { |
| 286 | var parts []string | 286 | var parts []string | ||
| 287 | switch { | 287 | switch { | ||
| 288 | - | case f.GetBinary(): | 288 | + | case f.Binary: |
| 289 | parts = append(parts, "binary file: no text diff") | 289 | parts = append(parts, "binary file: no text diff") | ||
| 290 | - | case f.GetTruncated(): | 290 | + | case f.Truncated: |
| 291 | - | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions())) | 291 | + | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions)) |
| 292 | } | 292 | } | ||
| 293 | - | if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() { | 293 | + | if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode { |
| 294 | - | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode())) | 294 | + | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode)) |
| 295 | } | 295 | } | ||
| 296 | - | switch f.GetStatus() { | 296 | + | switch f.Status { |
| 297 | - | case pb.FileStatus_FILE_STATUS_RENAMED: | 297 | + | case domain.FileRenamed: |
| 298 | - | parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity())) | 298 | + | parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity)) |
| 299 | - | case pb.FileStatus_FILE_STATUS_COPIED: | 299 | + | case domain.FileCopied: |
| 300 | - | parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity())) | 300 | + | parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity)) |
| 301 | - | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | 301 | + | case domain.FileTypeChanged: |
| 302 | parts = append(parts, "type changed") | 302 | parts = append(parts, "type changed") | ||
| 303 | } | 303 | } | ||
| 304 | return strings.Join(parts, " · ") | 304 | return strings.Join(parts, " · ") | ||
M internal/adapters/web/diff.templ +3 -4
| @@ -106,8 +106,8 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||||
| 106 | aria-current="true" | 106 | aria-current="true" | ||
| 107 | } | 107 | } | ||
| 108 | if p.Both { | 108 | if p.Both { | ||
| 109 | - | hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" | 109 | + | data-fire={ view } |
| 110 | - | :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" } | 110 | + | :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false" |
| 111 | } | 111 | } | ||
| 112 | >{ label }<kbd aria-hidden="true">{ key }</kbd></a> | 112 | >{ label }<kbd aria-hidden="true">{ key }</kbd></a> | ||
| 113 | } | 113 | } | ||
| @@ -115,7 +115,7 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||||
| 115 | // collapseButton folds every file of the diff, or opens them all again when | 115 | // collapseButton folds every file of the diff, or opens them all again when | ||
| 116 | // none is open; its label follows the state. | 116 | // none is open; its label follows the state. | ||
| 117 | templ collapseButton() { | 117 | templ collapseButton() { | ||
| 118 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 118 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 119 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span><kbd aria-hidden="true">x</kbd> | 119 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span><kbd aria-hidden="true">x</kbd> | ||
| 120 | </button> | 120 | </button> | ||
| 121 | } | 121 | } | ||
| @@ -142,7 +142,6 @@ templ FileList(files []DiffFile) { | |||||
| 142 | <li> | 142 | <li> | ||
| 143 | <a | 143 | <a | ||
| 144 | href={ templ.SafeURL("#" + f.ID) } | 144 | href={ templ.SafeURL("#" + f.ID) } | ||
| 145 | - | :aria-current={ "(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === '" + f.ID + "' ? 'true' : false })()" } | |||
| 146 | hx-on:click="document.getElementById(this.hash.slice(1)).open = true" | 145 | hx-on:click="document.getElementById(this.hash.slice(1)).open = true" | ||
| 147 | > | 146 | > | ||
| 148 | <span class="st" title={ f.Status }>{ f.Status }</span> | 147 | <span class="st" title={ f.Status }>{ f.Status }</span> | ||
M internal/adapters/web/diff_templ.go +199 -212 generated file: collapsed
M internal/adapters/web/diff_test.go +43 -16
| @@ -10,13 +10,14 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // Trojan Source (ADR 0017): direction controls, zero-width and control | 16 | // Trojan Source (ADR 0017): direction controls, zero-width and control | ||
| 17 | // characters are shown as markers; ordinary text, tabs and non-Latin letters | 17 | // characters are shown as markers; ordinary text, tabs and non-Latin letters | ||
| 18 | // are not touched. | 18 | // are not touched. | ||
| 19 | func TestSegmentsShowInvisibleCharacters(t *testing.T) { | 19 | func TestSegmentsShowInvisibleCharacters(t *testing.T) { | ||
| 20 | + | t.Parallel() | |||
| 20 | tests := []struct { | 21 | tests := []struct { | ||
| 21 | name, in string | 22 | name, in string | ||
| 22 | want []Seg | 23 | want []Seg | ||
| @@ -35,6 +36,7 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) { | |||||
| 35 | } | 36 | } | ||
| 36 | for _, tt := range tests { | 37 | for _, tt := range tests { | ||
| 37 | t.Run(tt.name, func(t *testing.T) { | 38 | t.Run(tt.name, func(t *testing.T) { | ||
| 39 | + | t.Parallel() | |||
| 38 | assert.Equal(t, tt.want, segments(tt.in)) | 40 | assert.Equal(t, tt.want, segments(tt.in)) | ||
| 39 | }) | 41 | }) | ||
| 40 | } | 42 | } | ||
| @@ -43,17 +45,18 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) { | |||||
| 43 | assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n")) | 45 | assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n")) | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 46 | - | func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line { | 48 | + | func line(kind domain.LineKind, oldN, newN int, text string) domain.Line { |
| 47 | - | return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text} | 49 | + | return domain.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text} |
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| 50 | // The split view lines a run of deletions up with the additions after it; | 52 | // The split view lines a run of deletions up with the additions after it; | ||
| 51 | // context fills both sides; the extra lines of the longer run have an empty | 53 | // context fills both sides; the extra lines of the longer run have an empty | ||
| 52 | // opposite side. | 54 | // opposite side. | ||
| 53 | func TestPairLines(t *testing.T) { | 55 | func TestPairLines(t *testing.T) { | ||
| 54 | - | ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION | 56 | + | t.Parallel() |
| 57 | + | ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion | |||
| 55 | var ls []DiffLine | 58 | var ls []DiffLine | ||
| 56 | - | for _, l := range []*pb.Line{ | 59 | + | for _, l := range []domain.Line{ |
| 57 | line(ctx, 1, 1, "c"), | 60 | line(ctx, 1, 1, "c"), | ||
| 58 | line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"), | 61 | line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"), | ||
| 59 | line(add, 0, 2, "a1"), | 62 | line(add, 0, 2, "a1"), | ||
| @@ -83,6 +86,7 @@ func TestPairLines(t *testing.T) { | |||||
| 83 | } | 86 | } | ||
| 84 | 87 | ||||
| 85 | func TestChangeBar(t *testing.T) { | 88 | func TestChangeBar(t *testing.T) { | ||
| 89 | + | t.Parallel() | |||
| 86 | assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0)) | 90 | assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0)) | ||
| 87 | assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100)) | 91 | assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100)) | ||
| 88 | assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100)) | 92 | assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100)) | ||
| @@ -91,19 +95,41 @@ func TestChangeBar(t *testing.T) { | |||||
| 91 | } | 95 | } | ||
| 92 | 96 | ||||
| 93 | func TestFileNotes(t *testing.T) { | 97 | func TestFileNotes(t *testing.T) { | ||
| 94 | - | assert.Equal(t, "binary file: no text diff", fileNote(&pb.FileDiff{Binary: true, Status: pb.FileStatus_FILE_STATUS_MODIFIED})) | 98 | + | t.Parallel() |
| 95 | - | assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755})) | 99 | + | tests := []struct { |
| 96 | - | assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change") | 100 | + | name string |
| 97 | - | assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644})) | 101 | + | file domain.FileDiff |
| 98 | - | assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large") | 102 | + | want string // a substring; empty means no note at all |
| 103 | + | eq bool // want is the whole note | |||
| 104 | + | }{ | |||
| 105 | + | {"binary", domain.FileDiff{Binary: true, Status: domain.FileModified}, "binary file: no text diff", true}, | |||
| 106 | + | {"mode change", domain.FileDiff{Status: domain.FileModified, OldMode: 0o100644, NewMode: 0o100755}, "mode 100644 → 100755", true}, | |||
| 107 | + | {"an added file has no mode change", domain.FileDiff{Status: domain.FileAdded, NewMode: 0o100644}, "", true}, | |||
| 108 | + | {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true}, | |||
| 109 | + | {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true}, | |||
| 110 | + | {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true}, | |||
| 111 | + | {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false}, | |||
| 112 | + | } | |||
| 113 | + | for _, tt := range tests { | |||
| 114 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 115 | + | t.Parallel() | |||
| 116 | + | got := fileNote(tt.file) | |||
| 117 | + | if tt.eq { | |||
| 118 | + | assert.Equal(t, tt.want, got) | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | assert.Contains(t, got, tt.want) | |||
| 122 | + | }) | |||
| 123 | + | } | |||
| 99 | } | 124 | } | ||
| 100 | 125 | ||||
| 101 | func TestDiffFilesPaths(t *testing.T) { | 126 | func TestDiffFilesPaths(t *testing.T) { | ||
| 102 | - | fs := diffFiles([]*pb.FileDiff{ | 127 | + | t.Parallel() |
| 103 | - | {Status: pb.FileStatus_FILE_STATUS_ADDED, NewPath: "a.go", Additions: 1, Hunks: []*pb.Hunk{{Lines: []*pb.Line{line(pb.LineKind_LINE_KIND_ADDITION, 0, 1, "x")}}}}, | 128 | + | fs := diffFiles([]domain.FileDiff{ |
| 104 | - | {Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1}, | 129 | + | {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}}, |
| 105 | - | {Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"}, | 130 | + | {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1}, |
| 106 | - | {Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000}, | 131 | + | {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"}, |
| 132 | + | {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000}, | |||
| 107 | }) | 133 | }) | ||
| 108 | assert.Equal(t, "a.go", fs[0].Path) | 134 | assert.Equal(t, "a.go", fs[0].Path) | ||
| 109 | assert.True(t, fs[0].Open) | 135 | assert.True(t, fs[0].Open) | ||
| @@ -117,8 +143,9 @@ func TestDiffFilesPaths(t *testing.T) { | |||||
| 117 | // A code cell holds exactly the text: escaped, with markers, and nothing | 143 | // A code cell holds exactly the text: escaped, with markers, and nothing | ||
| 118 | // added between the pieces (the cell keeps its white space). | 144 | // added between the pieces (the cell keeps its white space). | ||
| 119 | func TestCodeLine(t *testing.T) { | 145 | func TestCodeLine(t *testing.T) { | ||
| 146 | + | t.Parallel() | |||
| 120 | var b strings.Builder | 147 | var b strings.Builder | ||
| 121 | - | ln := diffLine(&pb.Line{Kind: pb.LineKind_LINE_KIND_ADDITION, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEof: true}) | 148 | + | ln := diffLine(domain.Line{Kind: domain.LineAddition, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEOF: true}) |
| 122 | require.NoError(t, codeLine(ln).Render(context.Background(), &b)) | 149 | require.NoError(t, codeLine(ln).Render(context.Background(), &b)) | ||
| 123 | assert.Equal(t, `a <b> <span class="inv" title="invisible character"><U+202E></span> c d`+ | 150 | assert.Equal(t, `a <b> <span class="inv" title="invisible character"><U+202E></span> c d`+ | ||
| 124 | `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String()) | 151 | `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String()) | ||
M internal/adapters/web/errors_test.go +143 -48
| @@ -6,41 +6,27 @@ import ( | |||||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | + | "fmt" | |||
| 9 | "net/http" | 10 | "net/http" | ||
| 10 | "net/http/httptest" | 11 | "net/http/httptest" | ||
| 11 | "regexp" | 12 | "regexp" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | 14 | ||||
| 14 | - | "connectrpc.com/connect" | |||
| 15 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 16 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 17 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 18 | 19 | ||||
| 19 | - | pb "bnema.dev/ermite-api/gen/ermite" | 20 | + | "bnema.dev/ermite/internal/domain" |
| 20 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 21 | + | "bnema.dev/ermite/internal/ports" |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 21 | ) | 23 | ) | ||
| 22 | 24 | ||||
| 23 | - | // failing is a repository client that fails every call with err. | 25 | + | // serve asks the web for path while every read of a repository fails with err, |
| 24 | - | type failing struct { | 26 | + | // and returns the answer and what was logged. |
| 25 | - | ermiteconnect.RepoServiceClient | |||
| 26 | - | err error | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func (f failing) ListRepos(context.Context, *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | |||
| 30 | - | return nil, f.err | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func (f failing) GetRepo(context.Context, *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |||
| 34 | - | return nil, f.err | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) { | 27 | func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) { | ||
| 38 | t.Helper() | 28 | t.Helper() | ||
| 39 | - | srv, nerr := New(Config{ | 29 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, err), Browse: mocks.NewBrowseService(t)}) |
| 40 | - | Repos: failing{err: err}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 41 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 42 | - | }) | |||
| 43 | - | require.NoError(t, nerr) | |||
| 44 | var logs bytes.Buffer | 30 | var logs bytes.Buffer | ||
| 45 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | 31 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | ||
| 46 | rec := httptest.NewRecorder() | 32 | rec := httptest.NewRecorder() | ||
| @@ -48,37 +34,146 @@ func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, st | |||||
| 48 | return rec, logs.String() | 34 | return rec, logs.String() | ||
| 49 | } | 35 | } | ||
| 50 | 36 | ||||
| 51 | - | // Errors of the API: not found (and an invalid name) are the 404 page, any | 37 | + | // Errors of the use cases: not found (and an invalid name) are the 404 page, a |
| 52 | - | // other is the 500 page with an id that the log carries. | 38 | + | // visitor that left gets nothing, any other is the 500 page with an id that |
| 39 | + | // the log carries. | |||
| 53 | func TestErrorPages(t *testing.T) { | 40 | func TestErrorPages(t *testing.T) { | ||
| 54 | - | rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone") | 41 | + | t.Parallel() |
| 55 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | 42 | + | tests := []struct { |
| 56 | - | assert.Contains(t, rec.Body.String(), "not found") | 43 | + | name string |
| 57 | - | assert.Empty(t, logs, "a 404 is not an error") | 44 | + | err error |
| 45 | + | path string | |||
| 46 | + | want int | |||
| 47 | + | }{ | |||
| 48 | + | {"not found", fmt.Errorf("repository: %w", domain.ErrNotFound), "/gone", http.StatusNotFound}, | |||
| 49 | + | {"invalid repository name", domain.ErrInvalidName, "/Bad", http.StatusNotFound}, | |||
| 50 | + | {"invalid argument", domain.ErrInvalid, "/demo", http.StatusNotFound}, | |||
| 51 | + | {"invalid page token", domain.ErrInvalidPageToken, "/demo", http.StatusNotFound}, | |||
| 52 | + | {"invalid revision", domain.ErrInvalidRevision, "/demo", http.StatusNotFound}, | |||
| 53 | + | {"invalid path", domain.ErrInvalidPath, "/demo", http.StatusNotFound}, | |||
| 54 | + | {"internal", errors.New("dial tcp 10.0.0.5: kv exploded"), "/demo", http.StatusInternalServerError}, | |||
| 55 | + | {"internal on the home page", errors.New("kv exploded on the list"), "/", http.StatusInternalServerError}, | |||
| 56 | + | {"forbidden is not a page of its own", domain.ErrForbidden, "/demo", http.StatusInternalServerError}, | |||
| 57 | + | {"resource exhausted", domain.ErrResourceExhausted, "/demo", http.StatusInternalServerError}, | |||
| 58 | + | {"deadline", context.DeadlineExceeded, "/demo", http.StatusInternalServerError}, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | rec, logs := serve(t, tt.err, tt.path) | |||
| 64 | + | assert.Equal(t, tt.want, rec.Code) | |||
| 65 | + | if tt.want == http.StatusNotFound { | |||
| 66 | + | assert.Contains(t, rec.Body.String(), "not found") | |||
| 67 | + | assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays out of the page") | |||
| 68 | + | assert.Empty(t, logs, "a 404 is not an error") | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |||
| 72 | + | assert.Contains(t, rec.Body.String(), "internal error") | |||
| 73 | + | assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays in the log") | |||
| 74 | + | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | |||
| 75 | + | require.Len(t, m, 2, "the page shows a request id") | |||
| 76 | + | assert.Contains(t, logs, m[1], "the log carries the same id") | |||
| 77 | + | assert.Contains(t, logs, tt.err.Error()) | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 58 | 81 | ||||
| 59 | - | rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad") | 82 | + | // A visitor that left gets no page at all. |
| 60 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | 83 | + | func TestCanceledRequestGetsNoPage(t *testing.T) { |
| 61 | - | assert.NotContains(t, rec.Body.String(), "bad name") | 84 | + | t.Parallel() |
| 85 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, context.Canceled), Browse: mocks.NewBrowseService(t)}) | |||
| 86 | + | rec := httptest.NewRecorder() | |||
| 87 | + | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/demo", nil)) | |||
| 88 | + | assert.Equal(t, http.StatusOK, rec.Code, "nothing was written") | |||
| 89 | + | assert.Empty(t, rec.Body.String()) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // The caller of every use case call is the principal of the request: anonymous | |||
| 93 | + | // without a valid session, the owner with one. A private repository is as | |||
| 94 | + | // missing for the first as one that does not exist (the 404 page, never a 403). | |||
| 95 | + | func TestUseCasesGetThePrincipalOfTheRequest(t *testing.T) { | |||
| 96 | + | t.Parallel() | |||
| 97 | + | const token = "tok" | |||
| 98 | + | tests := []struct { | |||
| 99 | + | name string | |||
| 100 | + | cookie string | |||
| 101 | + | want domain.Principal | |||
| 102 | + | }{ | |||
| 103 | + | {"no cookie is anonymous", "", domain.Anonymous()}, | |||
| 104 | + | {"an invalid session is anonymous", "bad", domain.Anonymous()}, | |||
| 105 | + | {"a valid session is the owner", token, domain.Owner()}, | |||
| 106 | + | } | |||
| 107 | + | for _, tt := range tests { | |||
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 109 | + | t.Parallel() | |||
| 110 | + | repos := mocks.NewRepoService(t) | |||
| 111 | + | // The use case answers with the principal it was given: only the | |||
| 112 | + | // owner sees the private repository. | |||
| 113 | + | repos.EXPECT().Get(mock.Anything, tt.want, repoName(t, "secret")).RunAndReturn( | |||
| 114 | + | func(_ context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 115 | + | if !p.IsRoot() { | |||
| 116 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 117 | + | } | |||
| 118 | + | return domain.Repo{Name: name, Visibility: domain.VisibilityPrivate}, nil | |||
| 119 | + | }).Once() | |||
| 120 | + | if tt.want.IsOwner() { | |||
| 121 | + | repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "secret"), nil).Once() | |||
| 122 | + | repos.EXPECT().ListPage(mock.Anything, tt.want, mock.Anything, mock.Anything).Return(ports.RepoPage{}, nil).Once() | |||
| 123 | + | } | |||
| 124 | + | browse := mocks.NewBrowseService(t) | |||
| 125 | + | if tt.want.IsOwner() { | |||
| 126 | + | browse.EXPECT().ListRefs(mock.Anything, tt.want, repoName(t, "secret"), mock.Anything).Return(ports.RefPage{}, nil).Once() | |||
| 127 | + | } | |||
| 128 | + | srv := newTestServer(t, Config{Repos: repos, Browse: browse, Sessions: newSessions(t, token), Limiter: &countingLimiter{}}) | |||
| 129 | + | ||||
| 130 | + | req := httptest.NewRequest(http.MethodGet, "/secret/refs", nil) | |||
| 131 | + | if tt.cookie != "" { | |||
| 132 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: tt.cookie}) | |||
| 133 | + | } | |||
| 134 | + | rec := httptest.NewRecorder() | |||
| 135 | + | srv.ServeHTTP(rec, req) | |||
| 62 | 136 | ||||
| 63 | - | secret := "dial tcp 10.0.0.5: kv exploded" | 137 | + | if tt.want.IsOwner() { |
| 64 | - | rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo") | 138 | + | assert.Equal(t, http.StatusOK, rec.Code) |
| 65 | - | assert.Equal(t, http.StatusInternalServerError, rec.Code) | 139 | + | return |
| 66 | - | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | 140 | + | } |
| 67 | - | assert.Contains(t, rec.Body.String(), "internal error") | 141 | + | assert.Equal(t, http.StatusNotFound, rec.Code, "a private repository is a missing one") |
| 68 | - | assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log") | 142 | + | assert.Contains(t, rec.Body.String(), "not found") |
| 69 | - | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | 143 | + | }) |
| 70 | - | require.Len(t, m, 2, "the page shows a request id") | 144 | + | } |
| 71 | - | assert.Contains(t, logs, m[1], "the log carries the same id") | 145 | + | } |
| 72 | - | assert.Contains(t, logs, "kv exploded") | 146 | + | |
| 147 | + | // A request that arrives with a principal in its context is anonymous anyway: | |||
| 148 | + | // only a valid session makes the owner. | |||
| 149 | + | func TestRequestContextCannotSetThePrincipal(t *testing.T) { | |||
| 150 | + | t.Parallel() | |||
| 151 | + | repos := mocks.NewRepoService(t) | |||
| 152 | + | repos.EXPECT().Get(mock.Anything, domain.Anonymous(), repoName(t, "secret")).Return(domain.Repo{}, domain.ErrNotFound).Once() | |||
| 153 | + | srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t)}) | |||
| 73 | 154 | ||||
| 74 | - | rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/") | 155 | + | ctx := domain.ContextWithPrincipal(context.Background(), domain.Root()) |
| 75 | - | assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too") | 156 | + | rec := httptest.NewRecorder() |
| 157 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/secret", nil)) | |||
| 158 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 76 | } | 159 | } | ||
| 77 | 160 | ||||
| 78 | func TestNewValidates(t *testing.T) { | 161 | func TestNewValidates(t *testing.T) { | ||
| 79 | - | _, err := New(Config{PublicURL: "https://forge.example"}) | 162 | + | t.Parallel() |
| 80 | - | assert.Error(t, err, "clients are required") | 163 | + | tests := []struct { |
| 81 | - | git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid") | 164 | + | name string |
| 82 | - | _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"}) | 165 | + | cfg Config |
| 83 | - | assert.Error(t, err) | 166 | + | }{ |
| 167 | + | {"use cases are required", Config{PublicURL: testPublicURL}}, | |||
| 168 | + | {"browse is required", Config{Repos: mocks.NewRepoService(t), PublicURL: testPublicURL}}, | |||
| 169 | + | {"repos are required", Config{Browse: mocks.NewBrowseService(t), PublicURL: testPublicURL}}, | |||
| 170 | + | {"the public URL must be one", Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t), PublicURL: "not a url"}}, | |||
| 171 | + | } | |||
| 172 | + | for _, tt := range tests { | |||
| 173 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 174 | + | t.Parallel() | |||
| 175 | + | _, err := New(tt.cfg) | |||
| 176 | + | assert.Error(t, err) | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 84 | } | 179 | } | ||
M internal/adapters/web/events.go +74 -45
| @@ -5,22 +5,25 @@ package web | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | + | "fmt" | |||
| 8 | "io" | 9 | "io" | ||
| 9 | "net/http" | 10 | "net/http" | ||
| 10 | "net/url" | 11 | "net/url" | ||
| 11 | "strconv" | 12 | "strconv" | ||
| 12 | "strings" | 13 | "strings" | ||
| 14 | + | "sync" | |||
| 13 | "time" | 15 | "time" | ||
| 14 | 16 | ||||
| 15 | - | "connectrpc.com/connect" | |||
| 16 | "github.com/bnema/zerowrap" | 17 | "github.com/bnema/zerowrap" | ||
| 17 | 18 | ||||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | 19 | + | "bnema.dev/ermite/internal/clientlimit" |
| 20 | + | "bnema.dev/ermite/internal/domain" | |||
| 21 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | ) | 22 | ) | ||
| 20 | 23 | ||||
| 21 | // Live updates. A page that can change under the eyes of its reader opens | 24 | // Live updates. A page that can change under the eyes of its reader opens | ||
| 22 | // GET /-/events (the Live component of the hint badge). The handler follows | 25 | // GET /-/events (the Live component of the hint badge). The handler follows | ||
| 23 | - | // EventService.Watch as the visitor of the request and turns each event into | 26 | + | // EventService.Watch as the principal of the request and turns each event into |
| 24 | // the HTML fragments the page shows, sent as Server-Sent Events: | 27 | // the HTML fragments the page shows, sent as Server-Sent Events: | ||
| 25 | // | 28 | // | ||
| 26 | // GET /-/events?topic=home | 29 | // GET /-/events?topic=home | ||
| @@ -51,7 +54,10 @@ import ( | |||||
| 51 | // changed: the page fetches itself again (ermiteLive in ermite.js) | 54 | // changed: the page fetches itself again (ermiteLive in ermite.js) | ||
| 52 | // | 55 | // | ||
| 53 | // The data of an event is one fragment, a top-level element with an id, one | 56 | // The data of an event is one fragment, a top-level element with an id, one | ||
| 54 | - | // "data:" line per line of HTML; the page swaps the element that has that id. | 57 | + | // "data:" line per line of HTML; the page swaps the element that has that id |
| 58 | + | // (ermiteLive.swap). The slots of the banners (commits-head, mr-notice) are | |||
| 59 | + | // status regions that stay in the page: only their children are replaced, so | |||
| 60 | + | // that a screen reader announces the banner. | |||
| 55 | // An event that changes nothing on the page is still sent as an id alone, so | 61 | // An event that changes nothing on the page is still sent as an id alone, so | ||
| 56 | // that a reconnection resumes after it. | 62 | // that a reconnection resumes after it. | ||
| 57 | 63 | ||||
| @@ -60,10 +66,6 @@ import ( | |||||
| 60 | const DefaultHeartbeat = 25 * time.Second | 66 | const DefaultHeartbeat = 25 * time.Second | ||
| 61 | 67 | ||||
| 62 | const ( | 68 | const ( | ||
| 63 | - | // startGrace is how long the handler waits after the Watch call answered | |||
| 64 | - | // for an early failure (too many watchers) before it commits to 200: the | |||
| 65 | - | // Connect protocol reports it in the stream, after the headers. | |||
| 66 | - | startGrace = 100 * time.Millisecond | |||
| 67 | // writeWindow bounds one write to the visitor, whatever the write timeout | 69 | // writeWindow bounds one write to the visitor, whatever the write timeout | ||
| 68 | // of the server is. | 70 | // of the server is. | ||
| 69 | writeWindow = 30 * time.Second | 71 | writeWindow = 30 * time.Second | ||
| @@ -102,7 +104,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 102 | if repo != "" { | 104 | if repo != "" { | ||
| 103 | // A repository the visitor cannot see is a missing one (AC2). | 105 | // A repository the visitor cannot see is a missing one (AC2). | ||
| 104 | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | 106 | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | ||
| 105 | - | _, err := s.cfg.Repos.GetRepo(cctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo})) | 107 | + | err := s.canSee(cctx, repo) |
| 106 | ccancel() | 108 | ccancel() | ||
| 107 | if err != nil { | 109 | if err != nil { | ||
| 108 | s.denyEvents(ctx, w, err) | 110 | s.denyEvents(ctx, w, err) | ||
| @@ -110,29 +112,44 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 110 | } | 112 | } | ||
| 111 | } | 113 | } | ||
| 112 | 114 | ||||
| 113 | - | stream, err := s.cfg.Events.Watch(ctx, connect.NewRequest(&pb.WatchRequest{ | 115 | + | // The watch runs in a goroutine and hands each event to this one through |
| 116 | + | // an unbuffered channel: a visitor that reads slowly holds the watch in | |||
| 117 | + | // its send, falls behind the buffer of the use case, and gets a gap. | |||
| 118 | + | msgs := make(chan recvd) | |||
| 119 | + | started := make(chan struct{}) | |||
| 120 | + | var startOnce sync.Once | |||
| 121 | + | start := func() { startOnce.Do(func() { close(started) }) } | |||
| 122 | + | finished := make(chan struct{}) | |||
| 123 | + | params := ports.WatchParams{ | |||
| 114 | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | 124 | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | ||
| 115 | - | })) | 125 | + | // The visitor is the client of the watch, for the limit per client. |
| 116 | - | if err != nil { | 126 | + | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), |
| 117 | - | s.denyEvents(ctx, w, err) | 127 | + | Ready: start, |
| 118 | - | return | |||
| 119 | } | 128 | } | ||
| 120 | - | // Wait for the answer of the API, which comes once the watch is registered | |||
| 121 | - | // (or refused), then read the stream from a goroutine. | |||
| 122 | - | _ = stream.ResponseHeader() | |||
| 123 | - | msgs := make(chan recvd) | |||
| 124 | - | done := make(chan struct{}) | |||
| 125 | go func() { | 129 | go func() { | ||
| 126 | - | defer close(done) | 130 | + | defer close(finished) |
| 127 | defer close(msgs) | 131 | defer close(msgs) | ||
| 128 | - | for stream.Receive() { | 132 | + | // net/http only recovers the handler goroutine: a panic in the watch |
| 133 | + | // would end the process. It ends this stream as an error instead. | |||
| 134 | + | defer func() { | |||
| 135 | + | if v := recover(); v != nil { | |||
| 136 | + | log.Error().Interface("panic", v).Msg("event watch panicked") | |||
| 137 | + | select { | |||
| 138 | + | case msgs <- recvd{err: fmt.Errorf("watch panicked: %v", v)}: | |||
| 139 | + | case <-ctx.Done(): | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | }() | |||
| 143 | + | err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error { | |||
| 144 | + | start() | |||
| 129 | select { | 145 | select { | ||
| 130 | - | case msgs <- recvd{ev: stream.Msg().GetEvent()}: | 146 | + | case msgs <- recvd{ev: we}: |
| 147 | + | return nil | |||
| 131 | case <-ctx.Done(): | 148 | case <-ctx.Done(): | ||
| 132 | - | return | 149 | + | return ctx.Err() |
| 133 | } | 150 | } | ||
| 134 | - | } | 151 | + | }) |
| 135 | - | if err := stream.Err(); err != nil { | 152 | + | if err != nil && ctx.Err() == nil { |
| 136 | select { | 153 | select { | ||
| 137 | case msgs <- recvd{err: err}: | 154 | case msgs <- recvd{err: err}: | ||
| 138 | case <-ctx.Done(): | 155 | case <-ctx.Done(): | ||
| @@ -141,26 +158,24 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 141 | }() | 158 | }() | ||
| 142 | defer func() { | 159 | defer func() { | ||
| 143 | cancel() | 160 | cancel() | ||
| 144 | - | _ = stream.Close() | 161 | + | <-finished |
| 145 | - | <-done | |||
| 146 | }() | 162 | }() | ||
| 147 | 163 | ||||
| 164 | + | // The watch is registered (or refused, which ends it before): only then | |||
| 165 | + | // does the answer commit to 200. | |||
| 148 | var first *recvd | 166 | var first *recvd | ||
| 149 | - | grace := time.NewTimer(startGrace) | |||
| 150 | select { | 167 | select { | ||
| 168 | + | case <-started: | |||
| 151 | case m, open := <-msgs: | 169 | case m, open := <-msgs: | ||
| 152 | - | grace.Stop() | |||
| 153 | if !open { | 170 | if !open { | ||
| 154 | return // the server is stopping | 171 | return // the server is stopping | ||
| 155 | } | 172 | } | ||
| 173 | + | if m.err != nil { | |||
| 174 | + | s.denyEvents(ctx, w, m.err) | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 156 | first = &m | 177 | first = &m | ||
| 157 | - | case <-grace.C: | |||
| 158 | case <-ctx.Done(): | 178 | case <-ctx.Done(): | ||
| 159 | - | grace.Stop() | |||
| 160 | - | return | |||
| 161 | - | } | |||
| 162 | - | if first != nil && first.err != nil { | |||
| 163 | - | s.denyEvents(ctx, w, first.err) | |||
| 164 | return | 179 | return | ||
| 165 | } | 180 | } | ||
| 166 | 181 | ||||
| @@ -182,11 +197,14 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 182 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | 197 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | ||
| 183 | handle := func(m recvd) bool { | 198 | handle := func(m recvd) bool { | ||
| 184 | if m.err != nil { | 199 | if m.err != nil { | ||
| 185 | - | if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled { | 200 | + | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { |
| 186 | log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended") | 201 | log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended") | ||
| 187 | } | 202 | } | ||
| 188 | return false | 203 | return false | ||
| 189 | } | 204 | } | ||
| 205 | + | if !liveEvent(m.ev) { | |||
| 206 | + | return true // nothing to show, not even an id to resume after | |||
| 207 | + | } | |||
| 190 | // An owner's stream ends when the session does, before it sends | 208 | // An owner's stream ends when the session does, before it sends | ||
| 191 | // anything more: an event can carry what only the owner may see, and | 209 | // anything more: an event can carry what only the owner may see, and | ||
| 192 | // waiting for the next heartbeat would leave a window. | 210 | // waiting for the next heartbeat would leave a window. | ||
| @@ -205,7 +223,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 205 | log.Warn().Err(err).Str("topic", topic).Msg("render live fragment") | 223 | log.Warn().Err(err).Str("topic", topic).Msg("render live fragment") | ||
| 206 | frags = gap | 224 | frags = gap | ||
| 207 | } | 225 | } | ||
| 208 | - | return out.send(m.ev.GetId(), frags) == nil | 226 | + | return out.send(m.ev.ID, frags) == nil |
| 209 | } | 227 | } | ||
| 210 | if first != nil && !handle(*first) { | 228 | if first != nil && !handle(*first) { | ||
| 211 | return | 229 | return | ||
| @@ -240,30 +258,41 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 240 | 258 | ||||
| 241 | // recvd is a message of the Watch stream, or how it failed. | 259 | // recvd is a message of the Watch stream, or how it failed. | ||
| 242 | type recvd struct { | 260 | type recvd struct { | ||
| 243 | - | ev *pb.Event | 261 | + | ev ports.WatchEvent |
| 244 | err error | 262 | err error | ||
| 245 | } | 263 | } | ||
| 246 | 264 | ||||
| 265 | + | // canSee checks that the caller of the request may see the repository named in | |||
| 266 | + | // a topic: one it may not is as missing as one that does not exist. | |||
| 267 | + | func (s *Server) canSee(ctx context.Context, rawName string) error { | |||
| 268 | + | name, err := domain.ParseRepoName(rawName) | |||
| 269 | + | if err != nil { | |||
| 270 | + | return err | |||
| 271 | + | } | |||
| 272 | + | _, err = s.cfg.Repos.Get(ctx, principal(ctx), name) | |||
| 273 | + | return err | |||
| 274 | + | } | |||
| 275 | + | ||||
| 247 | // denyStream answers a request that gets no stream. | 276 | // denyStream answers a request that gets no stream. | ||
| 248 | func denyStream(w http.ResponseWriter, status int, msg string) { | 277 | func denyStream(w http.ResponseWriter, status int, msg string) { | ||
| 249 | w.Header().Set("Cache-Control", "no-store") | 278 | w.Header().Set("Cache-Control", "no-store") | ||
| 250 | http.Error(w, msg, status) | 279 | http.Error(w, msg, status) | ||
| 251 | } | 280 | } | ||
| 252 | 281 | ||||
| 253 | - | // denyEvents maps an error of the API, before the stream started, to a | 282 | + | // denyEvents maps an error of a use case, before the stream started, to a |
| 254 | // status. Not found and invalid are 404 whatever the reason: a private | 283 | // status. Not found and invalid are 404 whatever the reason: a private | ||
| 255 | // repository is a missing one. | 284 | // repository is a missing one. | ||
| 256 | func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) { | 285 | func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) { | ||
| 257 | - | switch code := connect.CodeOf(err); { | 286 | + | switch { |
| 258 | - | case code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | 287 | + | case errors.Is(err, context.Canceled): |
| 288 | + | // The visitor is gone. | |||
| 289 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): | |||
| 259 | denyStream(w, http.StatusNotFound, "not found") | 290 | denyStream(w, http.StatusNotFound, "not found") | ||
| 260 | - | case code == connect.CodeResourceExhausted: | 291 | + | case errors.Is(err, domain.ErrResourceExhausted): |
| 261 | w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) | 292 | w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) | ||
| 262 | denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later") | 293 | denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later") | ||
| 263 | - | case code == connect.CodeUnauthenticated: | 294 | + | case errors.Is(err, domain.ErrUnauthenticated): |
| 264 | denyStream(w, http.StatusUnauthorized, "unauthorized") | 295 | denyStream(w, http.StatusUnauthorized, "unauthorized") | ||
| 265 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |||
| 266 | - | // The visitor is gone. | |||
| 267 | default: | 296 | default: | ||
| 268 | log := zerowrap.FromCtx(ctx) | 297 | log := zerowrap.FromCtx(ctx) | ||
| 269 | log.Error().Err(err).Msg("open event stream") | 298 | log.Error().Err(err).Msg("open event stream") | ||
M internal/adapters/web/events_test.go +155 -96
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "bufio" | 6 | "bufio" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | + | "fmt" | |||
| 9 | "io" | 10 | "io" | ||
| 10 | "net/http" | 11 | "net/http" | ||
| 11 | "net/http/httptest" | 12 | "net/http/httptest" | ||
| @@ -14,15 +15,17 @@ import ( | |||||
| 14 | "testing" | 15 | "testing" | ||
| 15 | "time" | 16 | "time" | ||
| 16 | 17 | ||||
| 17 | - | "connectrpc.com/connect" | |||
| 18 | "github.com/stretchr/testify/assert" | 18 | "github.com/stretchr/testify/assert" | ||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 19 | "github.com/stretchr/testify/require" | 20 | "github.com/stretchr/testify/require" | ||
| 20 | 21 | ||||
| 21 | - | pb "bnema.dev/ermite-api/gen/ermite" | 22 | + | "bnema.dev/ermite/internal/domain" |
| 22 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 23 | + | "bnema.dev/ermite/internal/ports" |
| 24 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 23 | ) | 25 | ) | ||
| 24 | 26 | ||||
| 25 | func TestFormatEvent(t *testing.T) { | 27 | func TestFormatEvent(t *testing.T) { | ||
| 28 | + | t.Parallel() | |||
| 26 | assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n", | 29 | assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n", | ||
| 27 | formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line") | 30 | formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line") | ||
| 28 | assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true)) | 31 | assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true)) | ||
| @@ -32,6 +35,7 @@ func TestFormatEvent(t *testing.T) { | |||||
| 32 | } | 35 | } | ||
| 33 | 36 | ||||
| 34 | func TestParseTopic(t *testing.T) { | 37 | func TestParseTopic(t *testing.T) { | ||
| 38 | + | t.Parallel() | |||
| 35 | for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | 39 | for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | ||
| 36 | repo, ok := parseTopic(in) | 40 | repo, ok := parseTopic(in) | ||
| 37 | assert.True(t, ok, in) | 41 | assert.True(t, ok, in) | ||
| @@ -44,38 +48,57 @@ func TestParseTopic(t *testing.T) { | |||||
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | func TestLiveURL(t *testing.T) { | 50 | func TestLiveURL(t *testing.T) { | ||
| 51 | + | t.Parallel() | |||
| 47 | assert.Equal(t, "/-/events?topic=home", liveURL("home", "")) | 52 | assert.Equal(t, "/-/events?topic=home", liveURL("home", "")) | ||
| 48 | assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs")) | 53 | assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs")) | ||
| 49 | } | 54 | } | ||
| 50 | 55 | ||||
| 51 | func TestNewCommitsText(t *testing.T) { | 56 | func TestNewCommitsText(t *testing.T) { | ||
| 57 | + | t.Parallel() | |||
| 52 | assert.Equal(t, "1 new commit — reload", newCommits("1", 1)) | 58 | assert.Equal(t, "1 new commit — reload", newCommits("1", 1)) | ||
| 53 | assert.Equal(t, "3 new commits — reload", newCommits("3", 3)) | 59 | assert.Equal(t, "3 new commits — reload", newCommits("3", 3)) | ||
| 54 | assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) | 60 | assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) | ||
| 55 | } | 61 | } | ||
| 56 | 62 | ||||
| 57 | - | // fakeEvents is an EventService whose behaviour a test sets. | 63 | + | // watchProbe drives the generated EventService mock: what the watch does, and |
| 58 | - | type fakeEvents struct { | 64 | + | // a record of the principal and the parameters of the watches it was asked for. |
| 59 | - | ermiteconnect.UnimplementedEventServiceHandler | 65 | + | type watchProbe struct { |
| 60 | err error | 66 | err error | ||
| 61 | - | events []*pb.Event | 67 | + | events []ports.WatchEvent |
| 62 | opened atomic.Int32 | 68 | opened atomic.Int32 | ||
| 63 | closed atomic.Int32 | 69 | closed atomic.Int32 | ||
| 64 | - | resume atomic.Value | 70 | + | resume atomic.Value // string: ResumeAfter of the last watch |
| 71 | + | topics atomic.Value // []string: Topics of the last watch | |||
| 72 | + | client atomic.Value // string: Client of the last watch | |||
| 73 | + | who atomic.Value // domain.Principal: caller of the last watch | |||
| 65 | release chan struct{} | 74 | release chan struct{} | ||
| 66 | - | push chan *pb.Event // events sent on demand, after the fixed ones | 75 | + | push chan ports.WatchEvent // events sent on demand, after the fixed ones |
| 67 | } | 76 | } | ||
| 68 | 77 | ||||
| 69 | - | func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error { | 78 | + | // service is the EventService the web watches: the mock, answering with the |
| 79 | + | // probe. | |||
| 80 | + | func (f *watchProbe) service(t testing.TB) *mocks.EventService { | |||
| 81 | + | t.Helper() | |||
| 82 | + | m := mocks.NewEventService(t) | |||
| 83 | + | m.EXPECT().Watch(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(f.run).Maybe() | |||
| 84 | + | return m | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (f *watchProbe) run(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error { | |||
| 70 | f.opened.Add(1) | 88 | f.opened.Add(1) | ||
| 71 | defer f.closed.Add(1) | 89 | defer f.closed.Add(1) | ||
| 72 | - | f.resume.Store(req.Msg.GetResumeAfter()) | 90 | + | f.resume.Store(params.ResumeAfter) |
| 91 | + | f.topics.Store(params.Topics) | |||
| 92 | + | f.client.Store(params.Client) | |||
| 93 | + | f.who.Store(p) | |||
| 73 | if f.err != nil { | 94 | if f.err != nil { | ||
| 74 | return f.err | 95 | return f.err | ||
| 75 | } | 96 | } | ||
| 76 | - | _ = st.Send(nil) | 97 | + | if params.Ready != nil { |
| 98 | + | params.Ready() | |||
| 99 | + | } | |||
| 77 | for _, ev := range f.events { | 100 | for _, ev := range f.events { | ||
| 78 | - | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | 101 | + | if err := send(ev); err != nil { |
| 79 | return err | 102 | return err | ||
| 80 | } | 103 | } | ||
| 81 | } | 104 | } | ||
| @@ -86,46 +109,29 @@ func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchReq | |||||
| 86 | case <-f.release: | 109 | case <-f.release: | ||
| 87 | return nil | 110 | return nil | ||
| 88 | case ev := <-f.push: | 111 | case ev := <-f.push: | ||
| 89 | - | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | 112 | + | if err := send(ev); err != nil { |
| 90 | return err | 113 | return err | ||
| 91 | } | 114 | } | ||
| 92 | } | 115 | } | ||
| 93 | } | 116 | } | ||
| 94 | } | 117 | } | ||
| 95 | 118 | ||||
| 96 | - | // repoOK answers every GetRepo with a public repository. | 119 | + | // eventsServer serves the web on a fake EventService, with a write timeout |
| 97 | - | type repoOK struct { | 120 | + | // shorter than the heartbeat, like a server that sets one. |
| 98 | - | ermiteconnect.UnimplementedRepoServiceHandler | 121 | + | func eventsServer(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration) *httptest.Server { |
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |||
| 102 | - | return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // eventsServer serves the web on a fake API, with a write timeout shorter | |||
| 106 | - | // than the heartbeat, like a server that sets one. | |||
| 107 | - | func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server { | |||
| 108 | t.Helper() | 122 | t.Helper() | ||
| 109 | return eventsServerWith(t, f, beat, writeTimeout, nil) | 123 | return eventsServerWith(t, f, beat, writeTimeout, nil) | ||
| 110 | } | 124 | } | ||
| 111 | 125 | ||||
| 112 | // eventsServerWith is eventsServer with sessions, when sess is not nil. | 126 | // eventsServerWith is eventsServer with sessions, when sess is not nil. | ||
| 113 | - | func eventsServerWith(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration, sess SessionAuth) *httptest.Server { | 127 | + | func eventsServerWith(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration, sess ports.WebSessionService) *httptest.Server { |
| 114 | t.Helper() | 128 | t.Helper() | ||
| 115 | - | api := http.NewServeMux() | 129 | + | srv := newTestServer(t, Config{ |
| 116 | - | api.Handle(ermiteconnect.NewEventServiceHandler(f)) | 130 | + | Repos: reposServing(t, publicRepo(t, "x")), |
| 117 | - | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | 131 | + | Browse: mocks.NewBrowseService(t), |
| 118 | - | backend := httptest.NewServer(api) | 132 | + | Events: f.service(t), |
| 119 | - | t.Cleanup(backend.Close) | |||
| 120 | - | ||||
| 121 | - | srv, err := New(Config{ | |||
| 122 | - | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |||
| 123 | - | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |||
| 124 | - | Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL), | |||
| 125 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 126 | Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess), | 133 | Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess), | ||
| 127 | }) | 134 | }) | ||
| 128 | - | require.NoError(t, err) | |||
| 129 | ts := httptest.NewUnstartedServer(srv) | 135 | ts := httptest.NewUnstartedServer(srv) | ||
| 130 | ts.Config.WriteTimeout = writeTimeout | 136 | ts.Config.WriteTimeout = writeTimeout | ||
| 131 | ts.Start() | 137 | ts.Start() | ||
| @@ -150,7 +156,8 @@ func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string | |||||
| 150 | // An idle stream gets a comment every heartbeat, and the write timeout of the | 156 | // An idle stream gets a comment every heartbeat, and the write timeout of the | ||
| 151 | // server does not end it. | 157 | // server does not end it. | ||
| 152 | func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | 158 | func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | ||
| 153 | - | f := &fakeEvents{release: make(chan struct{})} | 159 | + | t.Parallel() |
| 160 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 154 | ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) | 161 | ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) | ||
| 155 | resp, cancel := open(t, ts, "topic=home", nil) | 162 | resp, cancel := open(t, ts, "topic=home", nil) | ||
| 156 | require.Equal(t, http.StatusOK, resp.StatusCode) | 163 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| @@ -173,9 +180,10 @@ func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | |||||
| 173 | // The id of the event, its name and the lines of its data come through; the | 180 | // The id of the event, its name and the lines of its data come through; the | ||
| 174 | // Last-Event-ID header is the resume point; a Gap becomes a gap event. | 181 | // Last-Event-ID header is the resume point; a Gap becomes a gap event. | ||
| 175 | func TestEventsStreamsEventsAndResumes(t *testing.T) { | 182 | func TestEventsStreamsEventsAndResumes(t *testing.T) { | ||
| 176 | - | f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{ | 183 | + | t.Parallel() |
| 177 | - | {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}}, | 184 | + | f := &watchProbe{release: make(chan struct{}), events: []ports.WatchEvent{ |
| 178 | - | {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}, | 185 | + | {ID: "b-7", Event: domain.PushEvent{Repo: repoName(t, "x")}}, |
| 186 | + | {Gap: true}, | |||
| 179 | }} | 187 | }} | ||
| 180 | ts := eventsServer(t, f, time.Hour, 0) | 188 | ts := eventsServer(t, f, time.Hour, 0) | ||
| 181 | resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) | 189 | resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) | ||
| @@ -191,29 +199,92 @@ func TestEventsStreamsEventsAndResumes(t *testing.T) { | |||||
| 191 | assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") | 199 | assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") | ||
| 192 | assert.Contains(t, lines, "event: gap") | 200 | assert.Contains(t, lines, "event: gap") | ||
| 193 | assert.Equal(t, "b-6", f.resume.Load()) | 201 | assert.Equal(t, "b-6", f.resume.Load()) | ||
| 202 | + | assert.Equal(t, []string{"repo:x"}, f.topics.Load()) | |||
| 203 | + | assert.Equal(t, domain.Anonymous(), f.who.Load(), "a visitor watches as anonymous") | |||
| 204 | + | assert.NotEmpty(t, f.client.Load(), "the watch is attributed to the address of the visitor") | |||
| 194 | } | 205 | } | ||
| 195 | 206 | ||||
| 196 | - | // Too many watchers is a 503 the browser can retry, not a stream. | 207 | + | // Too many watchers is a 503 the browser can retry, not a stream; what the |
| 197 | - | func TestEventsResourceExhausted(t *testing.T) { | 208 | + | // watch is refused for decides the status. |
| 198 | - | f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))} | 209 | + | func TestEventsRefusals(t *testing.T) { |
| 199 | - | ts := eventsServer(t, f, time.Hour, 0) | 210 | + | t.Parallel() |
| 200 | - | resp, _ := open(t, ts, "topic=home", nil) | 211 | + | tests := []struct { |
| 201 | - | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | 212 | + | name string |
| 202 | - | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | 213 | + | err error |
| 203 | - | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | 214 | + | want int |
| 215 | + | retryAfter bool | |||
| 216 | + | }{ | |||
| 217 | + | {"resource exhausted", fmt.Errorf("%w: too many watchers", domain.ErrResourceExhausted), http.StatusServiceUnavailable, true}, | |||
| 218 | + | {"not found", domain.ErrNotFound, http.StatusNotFound, false}, | |||
| 219 | + | {"invalid", domain.ErrInvalid, http.StatusNotFound, false}, | |||
| 220 | + | {"unauthenticated", domain.ErrUnauthenticated, http.StatusUnauthorized, false}, | |||
| 221 | + | {"internal", errors.New("boom"), http.StatusInternalServerError, false}, | |||
| 222 | + | } | |||
| 223 | + | for _, tt := range tests { | |||
| 224 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 225 | + | t.Parallel() | |||
| 226 | + | ts := eventsServer(t, &watchProbe{err: tt.err}, time.Hour, 0) | |||
| 227 | + | resp, _ := open(t, ts, "topic=home", nil) | |||
| 228 | + | assert.Equal(t, tt.want, resp.StatusCode) | |||
| 229 | + | assert.Equal(t, tt.retryAfter, resp.Header.Get("Retry-After") != "") | |||
| 230 | + | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | |||
| 231 | + | assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) | |||
| 232 | + | }) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 204 | 235 | ||||
| 205 | - | f.err = connect.NewError(connect.CodeNotFound, errors.New("x")) | 236 | + | // A panic in the watch runs outside the handler goroutine, where net/http |
| 206 | - | resp, _ = open(t, ts, "topic=home", nil) | 237 | + | // would not recover it: it ends the stream with a 500 instead of the process. |
| 207 | - | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | 238 | + | func TestEventsWatchPanicIsAnError(t *testing.T) { |
| 208 | - | f.err = errors.New("boom") | 239 | + | t.Parallel() |
| 209 | - | resp, _ = open(t, ts, "topic=home", nil) | 240 | + | m := mocks.NewEventService(t) |
| 241 | + | m.EXPECT().Watch(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |||
| 242 | + | RunAndReturn(func(context.Context, domain.Principal, ports.WatchParams, func(ports.WatchEvent) error) error { | |||
| 243 | + | panic("boom") | |||
| 244 | + | }) | |||
| 245 | + | ts := httptest.NewServer(newTestServer(t, Config{ | |||
| 246 | + | Repos: reposServing(t, publicRepo(t, "x")), | |||
| 247 | + | Browse: mocks.NewBrowseService(t), | |||
| 248 | + | Events: m, | |||
| 249 | + | })) | |||
| 250 | + | t.Cleanup(ts.Close) | |||
| 251 | + | resp, _ := open(t, ts, "topic=home", nil) | |||
| 210 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | 252 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | ||
| 211 | } | 253 | } | ||
| 212 | 254 | ||||
| 255 | + | // The topic of a repository the visitor cannot see is refused before any | |||
| 256 | + | // watch is opened: a private repository is a missing one. | |||
| 257 | + | func TestEventsPrivateRepositoryTopic(t *testing.T) { | |||
| 258 | + | t.Parallel() | |||
| 259 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 260 | + | srv := newTestServer(t, Config{ | |||
| 261 | + | Repos: reposServing(t, publicRepo(t, "pub")), Browse: mocks.NewBrowseService(t), Events: f.service(t), | |||
| 262 | + | }) | |||
| 263 | + | ts := httptest.NewServer(srv) | |||
| 264 | + | t.Cleanup(ts.Close) | |||
| 265 | + | for _, query := range []string{"topic=repo:secret", "topic=repo:Bad"} { | |||
| 266 | + | resp, _ := open(t, ts, query, nil) | |||
| 267 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode, query) | |||
| 268 | + | } | |||
| 269 | + | assert.Zero(t, f.opened.Load(), "no watch was opened for them") | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // The owner's stream is the owner's: the watch is opened as the owner. | |||
| 273 | + | func TestEventsOwnerWatchesAsOwner(t *testing.T) { | |||
| 274 | + | t.Parallel() | |||
| 275 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 276 | + | sess, _ := newRevocable(t, "tok") | |||
| 277 | + | ts := eventsServerWith(t, f, time.Hour, 0, sess) | |||
| 278 | + | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | |||
| 279 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 280 | + | assert.Equal(t, domain.Owner(), f.who.Load()) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 213 | // The handler returns, and the watch it opened ends, when the visitor goes | 283 | // The handler returns, and the watch it opened ends, when the visitor goes | ||
| 214 | // away or the API ends the stream. | 284 | // away or the API ends the stream. | ||
| 215 | func TestEventsNoLeak(t *testing.T) { | 285 | func TestEventsNoLeak(t *testing.T) { | ||
| 216 | - | f := &fakeEvents{release: make(chan struct{})} | 286 | + | t.Parallel() |
| 287 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 217 | ts := eventsServer(t, f, time.Hour, 0) | 288 | ts := eventsServer(t, f, time.Hour, 0) | ||
| 218 | 289 | ||||
| 219 | resp, cancel := open(t, ts, "topic=home", nil) | 290 | resp, cancel := open(t, ts, "topic=home", nil) | ||
| @@ -236,13 +307,9 @@ func TestEventsNoLeak(t *testing.T) { | |||||
| 236 | require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) | 307 | require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) | ||
| 237 | } | 308 | } | ||
| 238 | 309 | ||||
| 239 | - | func TestEventsOffWithoutClient(t *testing.T) { | 310 | + | func TestEventsOffWithoutEventService(t *testing.T) { |
| 240 | - | srv, err := New(Config{ | 311 | + | t.Parallel() |
| 241 | - | Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"), | 312 | + | srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)}) |
| 242 | - | Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"), | |||
| 243 | - | PublicURL: "https://forge.example", | |||
| 244 | - | }) | |||
| 245 | - | require.NoError(t, err) | |||
| 246 | rec := httptest.NewRecorder() | 313 | rec := httptest.NewRecorder() | ||
| 247 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) | 314 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) | ||
| 248 | assert.Equal(t, http.StatusNotFound, rec.Code) | 315 | assert.Equal(t, http.StatusNotFound, rec.Code) | ||
| @@ -252,18 +319,10 @@ func TestEventsOffWithoutClient(t *testing.T) { | |||||
| 252 | // show it load themselves again: the summary and the refs page, whose rows | 319 | // show it load themselves again: the summary and the refs page, whose rows | ||
| 253 | // and marks depend on it. The commits page shows a branch, not the default. | 320 | // and marks depend on it. The commits page shows a branch, not the default. | ||
| 254 | func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { | 321 | func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { | ||
| 255 | - | api := http.NewServeMux() | 322 | + | t.Parallel() |
| 256 | - | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | 323 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t)}) |
| 257 | - | backend := httptest.NewServer(api) | |||
| 258 | - | t.Cleanup(backend.Close) | |||
| 259 | - | srv, err := New(Config{ | |||
| 260 | - | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |||
| 261 | - | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |||
| 262 | - | PublicURL: "https://forge.example", | |||
| 263 | - | }) | |||
| 264 | - | require.NoError(t, err) | |||
| 265 | 324 | ||||
| 266 | - | ev := &pb.Event{Kind: &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: &pb.Repo{Name: "x"}}}} | 325 | + | ev := ports.WatchEvent{Event: domain.RepoUpdated{Repo: repoName(t, "x")}} |
| 267 | for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} { | 326 | for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} { | ||
| 268 | ls := &liveStream{s: srv, repo: "x", view: view} | 327 | ls := &liveStream{s: srv, repo: "x", view: view} | ||
| 269 | got, err := ls.render(t.Context(), ev) | 328 | got, err := ls.render(t.Context(), ev) | ||
| @@ -276,30 +335,28 @@ func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { | |||||
| 276 | } | 335 | } | ||
| 277 | } | 336 | } | ||
| 278 | 337 | ||||
| 279 | - | func limiterFor(sess SessionAuth) FailureLimiter { | 338 | + | func limiterFor(sess ports.WebSessionService) ports.FailureLimiter { |
| 280 | if sess == nil { | 339 | if sess == nil { | ||
| 281 | return nil | 340 | return nil | ||
| 282 | } | 341 | } | ||
| 283 | return &countingLimiter{} | 342 | return &countingLimiter{} | ||
| 284 | } | 343 | } | ||
| 285 | 344 | ||||
| 286 | - | // revocable is a SessionAuth whose one session ends when the test says so, like | 345 | + | // newRevocable is a ports.WebSessionService whose one session ends when the test sets |
| 287 | - | // SignOutEverywhere does. | 346 | + | // ended, like SignOutEverywhere does. |
| 288 | - | type revocable struct { | 347 | + | func newRevocable(t *testing.T, valid string) (*mocks.WebSessionService, *atomic.Bool) { |
| 289 | - | fakeSessions | 348 | + | t.Helper() |
| 290 | - | ended atomic.Bool | 349 | + | var ended atomic.Bool |
| 291 | - | } | 350 | + | return newVerifier(t, valid, func(tok string) (bool, error) { return tok == valid && !ended.Load(), nil }), &ended |
| 292 | - | ||||
| 293 | - | func (r *revocable) Verify(ctx context.Context, tok string) (context.Context, bool, error) { | |||
| 294 | - | return ctx, tok == r.valid && !r.ended.Load(), nil | |||
| 295 | } | 351 | } | ||
| 296 | 352 | ||||
| 297 | // GO-002: the stream of the owner is checked before each event is sent, not | 353 | // GO-002: the stream of the owner is checked before each event is sent, not | ||
| 298 | // only on the heartbeat: after the session ended, the next event is not sent | 354 | // only on the heartbeat: after the session ended, the next event is not sent | ||
| 299 | // and the stream ends. | 355 | // and the stream ends. | ||
| 300 | func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | 356 | func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | ||
| 301 | - | f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} | 357 | + | t.Parallel() |
| 302 | - | sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}} | 358 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} |
| 359 | + | sess, ended := newRevocable(t, "tok") | |||
| 303 | ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test | 360 | ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test | ||
| 304 | resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | 361 | resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | ||
| 305 | require.Equal(t, http.StatusOK, resp.StatusCode) | 362 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| @@ -318,7 +375,7 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||||
| 318 | // The stream is up; a first event goes through while the session lives. | 375 | // The stream is up; a first event goes through while the session lives. | ||
| 319 | require.Equal(t, ": connected", line()) | 376 | require.Equal(t, ": connected", line()) | ||
| 320 | line() | 377 | line() | ||
| 321 | - | f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | 378 | + | f.push <- ports.WatchEvent{ID: "b-1", Gap: true} |
| 322 | var seen []string | 379 | var seen []string | ||
| 323 | for l := line(); l != "data: gap"; l = line() { | 380 | for l := line(); l != "data: gap"; l = line() { | ||
| 324 | seen = append(seen, l) | 381 | seen = append(seen, l) | ||
| @@ -327,9 +384,9 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||||
| 327 | assert.Contains(t, seen, "id: b-1") | 384 | assert.Contains(t, seen, "id: b-1") | ||
| 328 | 385 | ||||
| 329 | // Signed out everywhere: the next event is not sent, the stream ends. | 386 | // Signed out everywhere: the next event is not sent, the stream ends. | ||
| 330 | - | sess.ended.Store(true) | 387 | + | ended.Store(true) |
| 331 | select { | 388 | select { | ||
| 332 | - | case f.push <- &pb.Event{Id: "b-2", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}: | 389 | + | case f.push <- ports.WatchEvent{ID: "b-2", Gap: true}: |
| 333 | case <-time.After(5 * time.Second): | 390 | case <-time.After(5 * time.Second): | ||
| 334 | t.Fatal("the event was not delivered to the web") | 391 | t.Fatal("the event was not delivered to the web") | ||
| 335 | } | 392 | } | ||
| @@ -340,13 +397,14 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||||
| 340 | 397 | ||||
| 341 | // A visitor's stream does not depend on sessions: events keep flowing. | 398 | // A visitor's stream does not depend on sessions: events keep flowing. | ||
| 342 | func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | 399 | func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | ||
| 343 | - | f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} | 400 | + | t.Parallel() |
| 344 | - | sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}} | 401 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} |
| 345 | - | sess.ended.Store(true) | 402 | + | sess, ended := newRevocable(t, "tok") |
| 403 | + | ended.Store(true) | |||
| 346 | ts := eventsServerWith(t, f, time.Hour, 0, sess) | 404 | ts := eventsServerWith(t, f, time.Hour, 0, sess) | ||
| 347 | resp, _ := open(t, ts, "topic=home", nil) | 405 | resp, _ := open(t, ts, "topic=home", nil) | ||
| 348 | require.Equal(t, http.StatusOK, resp.StatusCode) | 406 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 349 | - | f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | 407 | + | f.push <- ports.WatchEvent{ID: "b-1", Gap: true} |
| 350 | sc := bufio.NewScanner(resp.Body) | 408 | sc := bufio.NewScanner(resp.Body) | ||
| 351 | found := false | 409 | found := false | ||
| 352 | for sc.Scan() { | 410 | for sc.Scan() { | ||
| @@ -361,8 +419,9 @@ func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | |||||
| 361 | // GO-006: a stream opened with an invalid cookie is anonymous, and its headers | 419 | // GO-006: a stream opened with an invalid cookie is anonymous, and its headers | ||
| 362 | // say the answer must not be stored and varies on the cookie. | 420 | // say the answer must not be stored and varies on the cookie. | ||
| 363 | func TestEventsInvalidCookieStream(t *testing.T) { | 421 | func TestEventsInvalidCookieStream(t *testing.T) { | ||
| 364 | - | f := &fakeEvents{release: make(chan struct{})} | 422 | + | t.Parallel() |
| 365 | - | ts := eventsServerWith(t, f, time.Hour, 0, &fakeSessions{valid: "tok"}) | 423 | + | f := &watchProbe{release: make(chan struct{})} |
| 424 | + | ts := eventsServerWith(t, f, time.Hour, 0, newSessions(t, "tok")) | |||
| 366 | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"}) | 425 | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"}) | ||
| 367 | require.Equal(t, http.StatusOK, resp.StatusCode) | 426 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 368 | assert.Equal(t, "no-store, no-transform", resp.Header.Get("Cache-Control")) | 427 | assert.Equal(t, "no-store, no-transform", resp.Header.Get("Cache-Control")) | ||
A internal/adapters/web/fanout_test.go +213 -0
| @@ -0,0 +1,213 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "testing/synctest" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | func frag(s string) []fragment { return []fragment{{event: "refs-list", html: s}} } | |||
| 23 | + | ||||
| 24 | + | func TestFanoutBuildsOncePerKey(t *testing.T) { | |||
| 25 | + | t.Parallel() | |||
| 26 | + | base := fanKey{repo: "x", event: "b-1"} | |||
| 27 | + | for _, tt := range []struct { | |||
| 28 | + | name string | |||
| 29 | + | keys []fanKey | |||
| 30 | + | built int32 | |||
| 31 | + | }{ | |||
| 32 | + | {"same key", []fanKey{base, base, base}, 1}, | |||
| 33 | + | {"owner and anonymous never share", []fanKey{base, {repo: "x", event: "b-1", owner: true}}, 2}, | |||
| 34 | + | {"another event", []fanKey{base, {repo: "x", event: "b-2"}}, 2}, | |||
| 35 | + | {"another repository", []fanKey{base, {repo: "y", event: "b-1"}}, 2}, | |||
| 36 | + | {"no event id: nothing to share by", []fanKey{{repo: "x"}, {repo: "x"}}, 2}, | |||
| 37 | + | } { | |||
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 39 | + | t.Parallel() | |||
| 40 | + | var f fanout | |||
| 41 | + | var built atomic.Int32 | |||
| 42 | + | for _, k := range tt.keys { | |||
| 43 | + | got, err := f.do(context.Background(), k, func() ([]fragment, error) { | |||
| 44 | + | built.Add(1) | |||
| 45 | + | return frag("for-owner=" + map[bool]string{true: "yes", false: "no"}[k.owner]), nil | |||
| 46 | + | }) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | assert.Equal(t, "for-owner="+map[bool]string{true: "yes", false: "no"}[k.owner], got[0].html, | |||
| 49 | + | "a watcher never gets the output built for the other class") | |||
| 50 | + | } | |||
| 51 | + | assert.Equal(t, tt.built, built.Load()) | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // While the first watcher builds, every other one waits for it: in a synctest | |||
| 57 | + | // bubble Wait returns once all goroutines are blocked, so the count of the | |||
| 58 | + | // watchers that entered and none that returned proves the others wait on the | |||
| 59 | + | // build instead of making their own. | |||
| 60 | + | func TestFanoutConcurrentWatchersShareOneBuild(t *testing.T) { | |||
| 61 | + | t.Parallel() | |||
| 62 | + | const watchers = 50 | |||
| 63 | + | synctest.Test(t, func(t *testing.T) { | |||
| 64 | + | var f fanout | |||
| 65 | + | var built, entered, returned atomic.Int32 | |||
| 66 | + | release := make(chan struct{}) | |||
| 67 | + | key := fanKey{repo: "x", event: "b-1"} | |||
| 68 | + | var wg sync.WaitGroup | |||
| 69 | + | results := make([][]fragment, watchers) | |||
| 70 | + | for i := range results { | |||
| 71 | + | wg.Add(1) | |||
| 72 | + | go func() { | |||
| 73 | + | defer wg.Done() | |||
| 74 | + | entered.Add(1) | |||
| 75 | + | results[i], _ = f.do(context.Background(), key, func() ([]fragment, error) { | |||
| 76 | + | built.Add(1) | |||
| 77 | + | <-release | |||
| 78 | + | return frag("once"), nil | |||
| 79 | + | }) | |||
| 80 | + | returned.Add(1) | |||
| 81 | + | }() | |||
| 82 | + | } | |||
| 83 | + | synctest.Wait() // the builder is on release, the others on its entry | |||
| 84 | + | require.Equal(t, int32(watchers), entered.Load()) | |||
| 85 | + | require.Equal(t, int32(1), built.Load(), "one builder") | |||
| 86 | + | require.Zero(t, returned.Load(), "the other %d wait for it", watchers-1) | |||
| 87 | + | ||||
| 88 | + | close(release) | |||
| 89 | + | wg.Wait() | |||
| 90 | + | assert.Equal(t, int32(1), built.Load()) | |||
| 91 | + | for _, r := range results { | |||
| 92 | + | assert.Equal(t, frag("once"), r) | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestFanoutFailureIsNotKept(t *testing.T) { | |||
| 98 | + | t.Parallel() | |||
| 99 | + | var f fanout | |||
| 100 | + | key := fanKey{repo: "x", event: "b-1"} | |||
| 101 | + | _, err := f.do(context.Background(), key, func() ([]fragment, error) { return nil, errors.New("boom") }) | |||
| 102 | + | require.Error(t, err) | |||
| 103 | + | got, err := f.do(context.Background(), key, func() ([]fragment, error) { return frag("ok"), nil }) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | assert.Equal(t, frag("ok"), got, "the next watcher builds again") | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestFanoutWaiterStopsWithItsContext(t *testing.T) { | |||
| 109 | + | t.Parallel() | |||
| 110 | + | var f fanout | |||
| 111 | + | key := fanKey{repo: "x", event: "b-1"} | |||
| 112 | + | started, release := make(chan struct{}), make(chan struct{}) | |||
| 113 | + | go func() { | |||
| 114 | + | _, _ = f.do(context.Background(), key, func() ([]fragment, error) { | |||
| 115 | + | close(started) | |||
| 116 | + | <-release | |||
| 117 | + | return frag("late"), nil | |||
| 118 | + | }) | |||
| 119 | + | }() | |||
| 120 | + | <-started | |||
| 121 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 122 | + | cancel() | |||
| 123 | + | _, err := f.do(ctx, key, func() ([]fragment, error) { return frag("never"), nil }) | |||
| 124 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 125 | + | close(release) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestFanoutIsBounded(t *testing.T) { | |||
| 129 | + | t.Parallel() | |||
| 130 | + | var f fanout | |||
| 131 | + | for i := range fanMax + 10 { | |||
| 132 | + | key := fanKey{repo: "x", event: "b-" + string(rune('A'+i%26)) + string(rune('a'+i/26))} | |||
| 133 | + | _, err := f.do(context.Background(), key, func() ([]fragment, error) { return frag("x"), nil }) | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | } | |||
| 136 | + | assert.LessOrEqual(t, len(f.m), fanMax) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // refsMocks is a web server over mocks that expect the refs fragment to load | |||
| 140 | + | // the repository and its refs once per build of each class of watcher | |||
| 141 | + | // (anonymous, owner), asking the use cases as that class's principal, and to | |||
| 142 | + | // ask for a commit once per build when commits is set. | |||
| 143 | + | func refsMocks(t *testing.T, anonymous, owner int, commits bool) *Server { | |||
| 144 | + | t.Helper() | |||
| 145 | + | const id = "0123456789abcdef0123456789abcdef01234567" | |||
| 146 | + | repos := mocks.NewRepoService(t) | |||
| 147 | + | browse := mocks.NewBrowseService(t) | |||
| 148 | + | for _, class := range []struct { | |||
| 149 | + | p domain.Principal | |||
| 150 | + | builds int | |||
| 151 | + | }{{domain.Anonymous(), anonymous}, {domain.Owner(), owner}} { | |||
| 152 | + | p, builds := class.p, class.builds | |||
| 153 | + | if builds == 0 { | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | repos.EXPECT().Get(mock.Anything, p, repoName(t, "x")).Return(publicRepo(t, "x").Repo, nil).Times(builds) | |||
| 157 | + | browse.EXPECT().ListRefs(mock.Anything, p, repoName(t, "x"), mock.MatchedBy(func(params ports.ListRefsParams) bool { | |||
| 158 | + | return params.PageSize == maxRefs // the refs are asked in one page | |||
| 159 | + | })).Return(ports.RefPage{Refs: []domain.Ref{ | |||
| 160 | + | {Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: objectID(t, id)}, | |||
| 161 | + | {Name: "refs/tags/v1", ShortName: "v1", Kind: domain.RefKindTag, CommitID: objectID(t, id)}, | |||
| 162 | + | }}, nil).Times(builds) | |||
| 163 | + | if commits { | |||
| 164 | + | browse.EXPECT().ListCommits(mock.Anything, p, repoName(t, "x"), mock.Anything).RunAndReturn( | |||
| 165 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error) { | |||
| 166 | + | return ports.HistoryPage{Commits: []domain.Commit{{ID: objectID(t, params.Revision.String()), Summary: "subject"}}}, nil | |||
| 167 | + | }).Times(builds) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "x"), nil).Times(anonymous + owner) | |||
| 171 | + | return newTestServer(t, Config{Repos: repos, Browse: browse}) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // A push is rendered once for the watchers of the same class, and the owner | |||
| 175 | + | // and the anonymous visitors never share what was built. | |||
| 176 | + | func TestLiveRefsSharesAPushBetweenWatchers(t *testing.T) { | |||
| 177 | + | t.Parallel() | |||
| 178 | + | owner := domain.ContextWithPrincipal(context.WithValue(context.Background(), signedInKey{}, struct{}{}), domain.Owner()) | |||
| 179 | + | anon := context.Background() | |||
| 180 | + | ||||
| 181 | + | for _, tt := range []struct { | |||
| 182 | + | name string | |||
| 183 | + | ctxs []context.Context | |||
| 184 | + | anonymous, mine int // builds: one per class | |||
| 185 | + | }{ | |||
| 186 | + | {"five anonymous watchers", []context.Context{anon, anon, anon, anon, anon}, 1, 0}, | |||
| 187 | + | {"owner and anonymous", []context.Context{anon, owner, anon, owner}, 1, 1}, | |||
| 188 | + | } { | |||
| 189 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 190 | + | t.Parallel() | |||
| 191 | + | s := refsMocks(t, tt.anonymous, tt.mine, true) | |||
| 192 | + | ev := ports.WatchEvent{ID: "b-1", Event: domain.PushEvent{Repo: repoName(t, "x")}} | |||
| 193 | + | for _, ctx := range tt.ctxs { | |||
| 194 | + | st := &liveStream{s: s, repo: "x", view: "refs"} | |||
| 195 | + | frags, err := st.render(ctx, ev) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | require.Len(t, frags, 2) | |||
| 198 | + | assert.Equal(t, "refs-list", frags[0].event) | |||
| 199 | + | assert.Contains(t, frags[0].html, `id="ref-rows"`) | |||
| 200 | + | } | |||
| 201 | + | }) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // The refs are asked in one page of up to maxRefs (the mock only answers that). | |||
| 206 | + | func TestLoadRepoAsksTheRefsInOnePage(t *testing.T) { | |||
| 207 | + | t.Parallel() | |||
| 208 | + | s := refsMocks(t, 1, 0, false) | |||
| 209 | + | d, err := s.loadRepo(context.Background(), "x") | |||
| 210 | + | require.NoError(t, err) | |||
| 211 | + | assert.Len(t, d.branches, 1) | |||
| 212 | + | assert.Len(t, d.tags, 1) | |||
| 213 | + | } | |||
M internal/adapters/web/fragments.go +206 -71
| @@ -5,12 +5,15 @@ package web | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | + | "errors" | |||
| 8 | "strconv" | 9 | "strconv" | ||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 9 | 12 | ||||
| 10 | - | "connectrpc.com/connect" | |||
| 11 | "github.com/a-h/templ" | 13 | "github.com/a-h/templ" | ||
| 12 | 14 | ||||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | 15 | + | "bnema.dev/ermite/internal/domain" |
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | ) | 17 | ) | ||
| 15 | 18 | ||||
| 16 | // liveStream turns the events of Watch into the fragments one page shows. | 19 | // liveStream turns the events of Watch into the fragments one page shows. | ||
| @@ -34,48 +37,73 @@ func renderFragment(ctx context.Context, event string, c templ.Component) (fragm | |||||
| 34 | 37 | ||||
| 35 | var gap = []fragment{{event: "gap", html: "gap"}} | 38 | var gap = []fragment{{event: "gap", html: "gap"}} | ||
| 36 | 39 | ||||
| 40 | + | // liveEvent reports whether the event of a watch is one the pages follow: a | |||
| 41 | + | // gap, or one of the events of the forge. The use case may send others, which | |||
| 42 | + | // no page shows and no stream carries, not even their id. | |||
| 43 | + | func liveEvent(we ports.WatchEvent) bool { | |||
| 44 | + | if we.Gap { | |||
| 45 | + | return true | |||
| 46 | + | } | |||
| 47 | + | switch we.Event.(type) { | |||
| 48 | + | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |||
| 49 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded: | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | ||||
| 37 | // render returns the fragments for an event, none when it changes nothing on | 55 | // render returns the fragments for an event, none when it changes nothing on | ||
| 38 | // the page. | 56 | // the page. | ||
| 39 | - | func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) { | 57 | + | func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { |
| 40 | - | if ev == nil { | 58 | + | if we.Gap { |
| 41 | - | return nil, nil | |||
| 42 | - | } | |||
| 43 | - | if ev.GetGap() != nil { | |||
| 44 | return gap, nil | 59 | return gap, nil | ||
| 45 | } | 60 | } | ||
| 61 | + | if we.Event == nil { | |||
| 62 | + | return nil, nil | |||
| 63 | + | } | |||
| 46 | if l.repo == "" { | 64 | if l.repo == "" { | ||
| 47 | - | return l.home(ctx, ev) | 65 | + | return l.home(ctx, we) |
| 66 | + | } | |||
| 67 | + | return l.repoPage(ctx, we) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // repoByName reads a repository as the caller of the request. A name that is | |||
| 71 | + | // not valid is a repository that is not there. | |||
| 72 | + | func (l *liveStream) repoByName(ctx context.Context, rawName string) (ports.RepoView, error) { | |||
| 73 | + | name, err := domain.ParseRepoName(rawName) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return ports.RepoView{}, domain.ErrNotFound | |||
| 48 | } | 76 | } | ||
| 49 | - | return l.repoPage(ctx, ev) | 77 | + | return l.s.repoView(ctx, name) |
| 50 | } | 78 | } | ||
| 51 | 79 | ||||
| 52 | // home upserts the row of the repository an event is about. The row is read | 80 | // home upserts the row of the repository an event is about. The row is read | ||
| 53 | - | // again from the API as the visitor of the stream, so a repository that was | 81 | + | // again from the use case as the principal of the stream, so a repository |
| 54 | - | // deleted or that the visitor can no longer see (public to private) removes | 82 | + | // that was deleted or that the visitor can no longer see (public to private) |
| 55 | - | // the row, and the stream never carries more than the list shows. | 83 | + | // removes the row, and the stream never carries more than the list shows. |
| 56 | - | func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) { | 84 | + | func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { |
| 57 | var name string | 85 | var name string | ||
| 58 | fresh := false | 86 | fresh := false | ||
| 59 | - | switch k := ev.GetKind().(type) { | 87 | + | switch ev := we.Event.(type) { |
| 60 | - | case *pb.Event_RepoDeleted: | 88 | + | case domain.RepoDeleted: |
| 61 | - | return l.gone(ctx, k.RepoDeleted.GetRepo()) | 89 | + | return l.gone(ctx, ev.Repo.String()) |
| 62 | - | case *pb.Event_RepoCreated: | 90 | + | case domain.RepoCreated: |
| 63 | - | name = k.RepoCreated.GetRepo().GetName() | 91 | + | name = ev.Repo.String() |
| 64 | - | case *pb.Event_RepoUpdated: | 92 | + | case domain.RepoUpdated: |
| 65 | - | name = k.RepoUpdated.GetRepo().GetName() | 93 | + | name = ev.Repo.String() |
| 66 | - | case *pb.Event_Pushed: | 94 | + | case domain.PushEvent: |
| 67 | - | name, fresh = k.Pushed.GetRepo(), true | 95 | + | name, fresh = ev.Repo.String(), true |
| 68 | default: | 96 | default: | ||
| 69 | return nil, nil | 97 | return nil, nil | ||
| 70 | } | 98 | } | ||
| 71 | - | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | 99 | + | view, err := l.repoByName(ctx, name) |
| 72 | switch { | 100 | switch { | ||
| 73 | - | case connect.CodeOf(err) == connect.CodeNotFound: | 101 | + | case errors.Is(err, domain.ErrNotFound): |
| 74 | return l.gone(ctx, name) | 102 | return l.gone(ctx, name) | ||
| 75 | case err != nil: | 103 | case err != nil: | ||
| 76 | return nil, err | 104 | return nil, err | ||
| 77 | } | 105 | } | ||
| 78 | - | row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false) | 106 | + | row := homeRow(view, l.s.cfg.Now(), false) |
| 79 | row.Fresh = fresh | 107 | row.Fresh = fresh | ||
| 80 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | 108 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | ||
| 81 | return []fragment{f}, err | 109 | return []fragment{f}, err | ||
| @@ -87,16 +115,16 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||||
| 87 | } | 115 | } | ||
| 88 | 116 | ||||
| 89 | // repoPage handles the events of the topic of one repository. | 117 | // repoPage handles the events of the topic of one repository. | ||
| 90 | - | func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) { | 118 | + | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { |
| 91 | - | switch k := ev.GetKind().(type) { | 119 | + | switch ev := we.Event.(type) { |
| 92 | - | case *pb.Event_RepoDeleted, *pb.Event_RepoCreated: | 120 | + | case domain.RepoDeleted, domain.RepoCreated: |
| 93 | // Gone, or a new repository under the same name: whatever the page | 121 | // Gone, or a new repository under the same name: whatever the page | ||
| 94 | // shows is not true any more. | 122 | // shows is not true any more. | ||
| 95 | return gap, nil | 123 | return gap, nil | ||
| 96 | - | case *pb.Event_RepoUpdated: | 124 | + | case domain.RepoUpdated: |
| 97 | - | _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | 125 | + | _, err := l.repoByName(ctx, l.repo) |
| 98 | switch { | 126 | switch { | ||
| 99 | - | case connect.CodeOf(err) == connect.CodeNotFound: | 127 | + | case errors.Is(err, domain.ErrNotFound): |
| 100 | return gap, nil // no longer visible | 128 | return gap, nil // no longer visible | ||
| 101 | case err != nil: | 129 | case err != nil: | ||
| 102 | return nil, err | 130 | return nil, err | ||
| @@ -104,26 +132,26 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er | |||||
| 104 | return gap, nil // the description, the default branch | 132 | return gap, nil // the description, the default branch | ||
| 105 | } | 133 | } | ||
| 106 | return nil, nil | 134 | return nil, nil | ||
| 107 | - | case *pb.Event_MergeRequestOpened: | 135 | + | case domain.MergeRequestOpened: |
| 108 | - | return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload") | 136 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload") |
| 109 | - | case *pb.Event_MergeRequestMerged: | 137 | + | case domain.MergeRequestMerged: |
| 110 | - | return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload") | 138 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload") |
| 111 | - | case *pb.Event_MergeRequestClosed: | 139 | + | case domain.MergeRequestClosed: |
| 112 | - | return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload") | 140 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") |
| 113 | - | case *pb.Event_CommentAdded: | 141 | + | case domain.CommentAdded: |
| 114 | - | return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload") | 142 | + | return l.mrNotice(ctx, ev.Number, "a comment was added — reload") |
| 115 | - | case *pb.Event_Pushed: | 143 | + | case domain.PushEvent: |
| 116 | switch l.view { | 144 | switch l.view { | ||
| 117 | case "mr": | 145 | case "mr": | ||
| 118 | - | return l.mrPushed(ctx, k.Pushed) | 146 | + | return l.mrPushed(ctx, ev) |
| 119 | case "mrs": | 147 | case "mrs": | ||
| 120 | return nil, nil | 148 | return nil, nil | ||
| 121 | } | 149 | } | ||
| 122 | switch l.view { | 150 | switch l.view { | ||
| 123 | case "refs": | 151 | case "refs": | ||
| 124 | - | return l.refs(ctx) | 152 | + | return l.refs(ctx, we.ID) |
| 125 | case "commits": | 153 | case "commits": | ||
| 126 | - | return l.banner(ctx, k.Pushed) | 154 | + | return l.banner(ctx, ev) |
| 127 | } | 155 | } | ||
| 128 | return l.lastPush(ctx) | 156 | return l.lastPush(ctx) | ||
| 129 | } | 157 | } | ||
| @@ -132,39 +160,144 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er | |||||
| 132 | 160 | ||||
| 133 | // lastPush is the "last push" fact of the summary. | 161 | // lastPush is the "last push" fact of the summary. | ||
| 134 | func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) { | 162 | func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) { | ||
| 135 | - | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | 163 | + | view, err := l.repoByName(ctx, l.repo) |
| 136 | if err != nil { | 164 | if err != nil { | ||
| 137 | return nil, err | 165 | return nil, err | ||
| 138 | } | 166 | } | ||
| 139 | - | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now()))) | 167 | + | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(view, l.s.cfg.Now()))) |
| 140 | return []fragment{f}, err | 168 | return []fragment{f}, err | ||
| 141 | } | 169 | } | ||
| 142 | 170 | ||||
| 143 | - | // refs is the table of the refs page and the facts of its rail. | 171 | + | // refs is the table of the refs page and the facts of its rail. The fragments |
| 144 | - | func (l *liveStream) refs(ctx context.Context) ([]fragment, error) { | 172 | + | // of one event are built once for all the watchers of the repository that |
| 145 | - | d, err := l.s.loadRepo(ctx, l.repo) | 173 | + | // see the same things (see fanout); eventID names the event. |
| 146 | - | if err != nil { | 174 | + | func (l *liveStream) refs(ctx context.Context, eventID string) ([]fragment, error) { |
| 147 | - | return nil, err | 175 | + | key := fanKey{repo: l.repo, event: eventID, owner: SignedIn(ctx)} |
| 176 | + | return l.s.refsFan.do(ctx, key, func() ([]fragment, error) { | |||
| 177 | + | d, err := l.s.loadRepo(ctx, l.repo) | |||
| 178 | + | if err != nil { | |||
| 179 | + | return nil, err | |||
| 180 | + | } | |||
| 181 | + | p := l.s.refsPage(ctx, d) | |||
| 182 | + | body, err := renderFragment(ctx, "refs-list", RefsBody(p)) | |||
| 183 | + | if err != nil { | |||
| 184 | + | return nil, err | |||
| 185 | + | } | |||
| 186 | + | kv, err := renderFragment(ctx, "refs-list", RefsKV(p)) | |||
| 187 | + | return []fragment{body, kv}, err | |||
| 188 | + | }) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | const ( | |||
| 192 | + | // fanTTL is how long the fragments of an event are kept for the watchers | |||
| 193 | + | // that are told of it a little later than the first. | |||
| 194 | + | fanTTL = 2 * time.Second | |||
| 195 | + | // fanMax bounds the events kept; past it an event is built for each | |||
| 196 | + | // watcher, as without sharing. | |||
| 197 | + | fanMax = 64 | |||
| 198 | + | ) | |||
| 199 | + | ||||
| 200 | + | // fanKey says which watchers may share the fragments of an event: those of | |||
| 201 | + | // the same repository, for the same event, and of the same class. The class | |||
| 202 | + | // is anonymous or owner (a session is the owner's alone, ADR 0018): what is | |||
| 203 | + | // built as the owner can show what an anonymous visitor may not see, so the | |||
| 204 | + | // two never share an entry. | |||
| 205 | + | type fanKey struct { | |||
| 206 | + | repo, event string | |||
| 207 | + | owner bool | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | type fanEntry struct { | |||
| 211 | + | done chan struct{} // closed once frags or err is set | |||
| 212 | + | frags []fragment | |||
| 213 | + | err error | |||
| 214 | + | at time.Time // when it was built; zero while it is | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // fanout builds the fragments of an event once for the many watchers that | |||
| 218 | + | // receive it: a push to a repository that 256 pages watch would otherwise | |||
| 219 | + | // load the refs and the commits they designate 256 times. The zero value is | |||
| 220 | + | // ready. A failure is never kept: the next watcher builds it again. | |||
| 221 | + | type fanout struct { | |||
| 222 | + | mu sync.Mutex | |||
| 223 | + | m map[fanKey]*fanEntry | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // do returns the fragments of key, built by build when nobody has or is | |||
| 227 | + | // building them. build runs with the context of the watcher that calls it, | |||
| 228 | + | // which is of the class of the key; a watcher whose builder failed (its | |||
| 229 | + | // request was cancelled, say) does not fail the others, they build again. | |||
| 230 | + | func (f *fanout) do(ctx context.Context, key fanKey, build func() ([]fragment, error)) ([]fragment, error) { | |||
| 231 | + | if key.event == "" { | |||
| 232 | + | return build() // no event to share by | |||
| 148 | } | 233 | } | ||
| 149 | - | p := l.s.refsPage(ctx, d) | 234 | + | for range 3 { |
| 150 | - | body, err := renderFragment(ctx, "refs-list", RefsBody(p)) | 235 | + | f.mu.Lock() |
| 151 | - | if err != nil { | 236 | + | now := time.Now() |
| 152 | - | return nil, err | 237 | + | for k, e := range f.m { |
| 238 | + | if !e.at.IsZero() && now.Sub(e.at) > fanTTL { | |||
| 239 | + | delete(f.m, k) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | e, shared := f.m[key] | |||
| 243 | + | if !shared { | |||
| 244 | + | if len(f.m) >= fanMax { | |||
| 245 | + | f.mu.Unlock() | |||
| 246 | + | return build() | |||
| 247 | + | } | |||
| 248 | + | if f.m == nil { | |||
| 249 | + | f.m = make(map[fanKey]*fanEntry) | |||
| 250 | + | } | |||
| 251 | + | e = &fanEntry{done: make(chan struct{})} | |||
| 252 | + | f.m[key] = e | |||
| 253 | + | } | |||
| 254 | + | f.mu.Unlock() | |||
| 255 | + | ||||
| 256 | + | if !shared { | |||
| 257 | + | return f.lead(key, e, build) | |||
| 258 | + | } | |||
| 259 | + | select { | |||
| 260 | + | case <-e.done: | |||
| 261 | + | case <-ctx.Done(): | |||
| 262 | + | return nil, ctx.Err() | |||
| 263 | + | } | |||
| 264 | + | if e.err == nil { | |||
| 265 | + | return e.frags, nil | |||
| 266 | + | } | |||
| 153 | } | 267 | } | ||
| 154 | - | kv, err := renderFragment(ctx, "refs-list", RefsKV(p)) | 268 | + | return build() |
| 155 | - | return []fragment{body, kv}, err | 269 | + | } |
| 270 | + | ||||
| 271 | + | // lead builds the entry that the caller registered. | |||
| 272 | + | func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) (frags []fragment, err error) { | |||
| 273 | + | err = errors.New("web: fragments were not built") // a panic of build | |||
| 274 | + | defer func() { | |||
| 275 | + | f.mu.Lock() | |||
| 276 | + | e.frags, e.err, e.at = frags, err, time.Now() | |||
| 277 | + | if err != nil && f.m[key] == e { | |||
| 278 | + | delete(f.m, key) | |||
| 279 | + | } | |||
| 280 | + | f.mu.Unlock() | |||
| 281 | + | close(e.done) | |||
| 282 | + | }() | |||
| 283 | + | return build() | |||
| 156 | } | 284 | } | ||
| 157 | 285 | ||||
| 158 | // banner tells the commits page how many commits its branch got since the | 286 | // banner tells the commits page how many commits its branch got since the | ||
| 159 | // head it lists. | 287 | // head it lists. | ||
| 160 | - | func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | 288 | + | // |
| 289 | + | //hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only | |||
| 290 | + | func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |||
| 161 | if l.ref == "" || !isFullID(l.head) { | 291 | if l.ref == "" || !isFullID(l.head) { | ||
| 162 | return nil, nil | 292 | return nil, nil | ||
| 163 | } | 293 | } | ||
| 164 | tip, touched := "", false | 294 | tip, touched := "", false | ||
| 165 | - | for _, u := range p.GetRefUpdates() { | 295 | + | for _, u := range p.Updates { |
| 166 | - | if u.GetRef() == "refs/heads/"+l.ref { | 296 | + | if u.Ref == "refs/heads/"+l.ref { |
| 167 | - | tip, touched = u.GetNewId(), true | 297 | + | tip, touched = u.New, true |
| 298 | + | if u.IsDelete() { | |||
| 299 | + | tip = "" | |||
| 300 | + | } | |||
| 168 | } | 301 | } | ||
| 169 | } | 302 | } | ||
| 170 | switch { | 303 | switch { | ||
| @@ -175,9 +308,11 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||||
| 175 | } | 308 | } | ||
| 176 | text := "" | 309 | text := "" | ||
| 177 | if tip != l.head { | 310 | if tip != l.head { | ||
| 178 | - | res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 311 | + | name, err := domain.ParseRepoName(l.repo) |
| 179 | - | Repo: l.repo, Revision: tip, PageSize: pageSize, | 312 | + | if err != nil { |
| 180 | - | })) | 313 | + | return nil, err |
| 314 | + | } | |||
| 315 | + | res, err := l.s.commitList(ctx, name, tip, "", pageSize, "") | |||
| 181 | if err != nil { | 316 | if err != nil { | ||
| 182 | return nil, err | 317 | return nil, err | ||
| 183 | } | 318 | } | ||
| @@ -185,10 +320,10 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||||
| 185 | // ordered by date, not as "head..tip", so commits that are not new may | 320 | // ordered by date, not as "head..tip", so commits that are not new may | ||
| 186 | // be counted before the head is met. And one page of the log being | 321 | // be counted before the head is met. And one page of the log being | ||
| 187 | // all that is read, "100+" cannot be told from a rewritten branch | 322 | // all that is read, "100+" cannot be told from a rewritten branch | ||
| 188 | - | // whose old head is further back: the API has no "is ancestor". | 323 | + | // whose old head is further back: the use cases have no "is ancestor". |
| 189 | n, found := 0, false | 324 | n, found := 0, false | ||
| 190 | - | for _, c := range res.Msg.GetCommits() { | 325 | + | for _, c := range res.Commits { |
| 191 | - | if c.GetId() == l.head { | 326 | + | if c.ID.String() == l.head { |
| 192 | found = true | 327 | found = true | ||
| 193 | break | 328 | break | ||
| 194 | } | 329 | } | ||
| @@ -197,13 +332,13 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||||
| 197 | switch { | 332 | switch { | ||
| 198 | case found: | 333 | case found: | ||
| 199 | text = newCommits(strconv.Itoa(n), n) | 334 | text = newCommits(strconv.Itoa(n), n) | ||
| 200 | - | case res.Msg.GetNextPageToken() != "": | 335 | + | case res.NextPageToken != "": |
| 201 | text = newCommits(strconv.Itoa(n)+"+", 2) | 336 | text = newCommits(strconv.Itoa(n)+"+", 2) | ||
| 202 | default: | 337 | default: | ||
| 203 | text = "the branch was rewritten — reload" | 338 | text = "the branch was rewritten — reload" | ||
| 204 | } | 339 | } | ||
| 205 | } | 340 | } | ||
| 206 | - | c := CommitsSlot() | 341 | + | c := LiveSlot("commits-head") |
| 207 | if text != "" { | 342 | if text != "" { | ||
| 208 | c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref)) | 343 | c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref)) | ||
| 209 | } | 344 | } | ||
| @@ -240,12 +375,12 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([ | |||||
| 240 | } | 375 | } | ||
| 241 | 376 | ||||
| 242 | // mrPushed announces a push to the source branch of the merge request shown. | 377 | // mrPushed announces a push to the source branch of the merge request shown. | ||
| 243 | - | func (l *liveStream) mrPushed(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | 378 | + | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { |
| 244 | if l.ref == "" { | 379 | if l.ref == "" { | ||
| 245 | return nil, nil | 380 | return nil, nil | ||
| 246 | } | 381 | } | ||
| 247 | - | for _, u := range p.GetRefUpdates() { | 382 | + | for _, u := range p.Updates { |
| 248 | - | if u.GetRef() == "refs/heads/"+l.ref { | 383 | + | if u.Ref == "refs/heads/"+l.ref { |
| 249 | n, err := strconv.ParseInt(l.n, 10, 64) | 384 | n, err := strconv.ParseInt(l.n, 10, 64) | ||
| 250 | if err != nil { | 385 | if err != nil { | ||
| 251 | return nil, nil | 386 | return nil, nil | ||
M internal/adapters/web/fragments.templ +22 -11
| @@ -55,31 +55,40 @@ templ RefsKV(p RefsPage) { | |||||
| 55 | </dl> | 55 | </dl> | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | - | // CommitsSlot is the place of the banner of the commits page, empty. | 58 | + | // LiveSlot is the place of a live banner, empty: a status region that stays |
| 59 | - | templ CommitsSlot() { | 59 | + | // in the page. The live updates (ermiteLive in ermite.js) replace its children, |
| 60 | - | <div id="commits-head"></div> | 60 | + | // never the element, so that a screen reader announces the new banner (a |
| 61 | + | // region that is inserted with its content is not announced). | |||
| 62 | + | templ LiveSlot(id string) { | |||
| 63 | + | <div id={ id } class="live-slot" role="status" data-slot></div> | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // liveBanner is the slot with its banner: a link that reloads or opens the | |||
| 67 | + | // page. Only the children of the element reach the page that has the slot. | |||
| 68 | + | templ liveBanner(id, text, href string) { | |||
| 69 | + | <div id={ id } class="live-slot" role="status" data-slot><a href={ templ.SafeURL(href) }>{ text }</a></div> | |||
| 61 | } | 70 | } | ||
| 62 | 71 | ||||
| 63 | // CommitsBanner announces new commits on the branch the page lists. It links | 72 | // CommitsBanner announces new commits on the branch the page lists. It links | ||
| 64 | // to the first page of the branch. | 73 | // to the first page of the branch. | ||
| 65 | templ CommitsBanner(text, href string) { | 74 | templ CommitsBanner(text, href string) { | ||
| 66 | - | <div id="commits-head" class="live-banner" role="status"> | 75 | + | @liveBanner("commits-head", text, href) |
| 67 | - | <a href={ templ.SafeURL(href) }>{ text }</a> | |||
| 68 | - | </div> | |||
| 69 | } | 76 | } | ||
| 70 | 77 | ||||
| 71 | - | // MRNotice tells a merge request page that it is out of date. The empty slot | 78 | + | // MRNotice tells a merge request page that it is out of date, or the list that |
| 72 | - | // of the page has the same id, so the first notice replaces it. | 79 | + | // its content changed. |
| 73 | templ MRNotice(text, href string) { | 80 | templ MRNotice(text, href string) { | ||
| 74 | - | <div id="mr-notice" class="live-banner" role="status"> | 81 | + | @liveBanner("mr-notice", text, href) |
| 75 | - | <a href={ templ.SafeURL(href) }>{ text }</a> | |||
| 76 | - | </div> | |||
| 77 | } | 82 | } | ||
| 78 | 83 | ||||
| 79 | // Live is the connection to the event stream, in the hint badge. It shows | 84 | // Live is the connection to the event stream, in the hint badge. It shows | ||
| 80 | // only once a connection was lost: a page that never connected, or that is | 85 | // only once a connection was lost: a page that never connected, or that is | ||
| 81 | // live, says nothing. The events of the stream are fragments that ermiteLive | 86 | // live, says nothing. The events of the stream are fragments that ermiteLive | ||
| 82 | // (ermite.js) swaps in the page. A page without a stream renders nothing. | 87 | // (ermite.js) swaps in the page. A page without a stream renders nothing. | ||
| 88 | + | // | |||
| 89 | + | // hx-sse retries a lost connection for ever, with a growing delay. A refusal | |||
| 90 | + | // that will not change (404: the repository is gone or hidden; 401) is handled | |||
| 91 | + | // by ermiteLive.error, which has the page fetch itself again. | |||
| 83 | templ Live(l Layout) { | 92 | templ Live(l Layout) { | ||
| 84 | if l.Live && l.LiveURL != "" { | 93 | if l.Live && l.LiveURL != "" { | ||
| 85 | <span | 94 | <span | ||
| @@ -90,6 +99,8 @@ templ Live(l Layout) { | |||||
| 90 | :text="data.state === 'offline' ? 'live updates paused' : ''" | 99 | :text="data.state === 'offline' ? 'live updates paused' : ''" | ||
| 91 | hx-on="htmx:sse:after:connection -> data.state = 'live'; | 100 | hx-on="htmx:sse:after:connection -> data.state = 'live'; | ||
| 92 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 101 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 102 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |||
| 103 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |||
| 93 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 104 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | ||
| 94 | gap -> ermiteLive.reload()" | 105 | gap -> ermiteLive.reload()" | ||
| 95 | ></span> | 106 | ></span> | ||
M internal/adapters/web/fragments_templ.go +94 -56 generated file: collapsed
A internal/adapters/web/fragments_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/a-h/templ" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func renderToString(t *testing.T, c templ.Component) string { | |||
| 16 | + | t.Helper() | |||
| 17 | + | var b bytes.Buffer | |||
| 18 | + | require.NoError(t, c.Render(t.Context(), &b)) | |||
| 19 | + | return b.String() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // A live banner is the content of a status region that stays in the page: the | |||
| 23 | + | // empty slot of the page and the fragment of the stream are the same element | |||
| 24 | + | // (same id, role and marker), so that ermiteLive can replace the children and | |||
| 25 | + | // leave the region where a screen reader watches it. | |||
| 26 | + | func TestLiveSlotsAreStableStatusRegions(t *testing.T) { | |||
| 27 | + | t.Parallel() | |||
| 28 | + | for _, tt := range []struct { | |||
| 29 | + | name string | |||
| 30 | + | c templ.Component | |||
| 31 | + | id string | |||
| 32 | + | text string | |||
| 33 | + | }{ | |||
| 34 | + | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | |||
| 35 | + | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | |||
| 36 | + | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | |||
| 37 | + | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | |||
| 38 | + | } { | |||
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | + | t.Parallel() | |||
| 41 | + | got := renderToString(t, tt.c) | |||
| 42 | + | assert.True(t, strings.HasPrefix(got, `<div id="`+tt.id+`" class="live-slot" role="status" data-slot>`), got) | |||
| 43 | + | if tt.text == "" { | |||
| 44 | + | assert.True(t, strings.HasSuffix(got, "data-slot></div>"), "an empty slot has no child: %s", got) | |||
| 45 | + | } else { | |||
| 46 | + | assert.Contains(t, got, tt.text) | |||
| 47 | + | } | |||
| 48 | + | assert.True(t, strings.HasSuffix(got, "</div>"), got) | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // The header's refs button is a link to the refs page, which the script turns | |||
| 54 | + | // into the dialog; the filters and the buttons that need scripts are marked. | |||
| 55 | + | func TestShellDegradesWithoutScripts(t *testing.T) { | |||
| 56 | + | t.Parallel() | |||
| 57 | + | l := Layout{Repo: "demo", Ref: "main", Short: "abc1234", Assets: Assets{}} | |||
| 58 | + | got := renderToString(t, Header(l)) | |||
| 59 | + | assert.Contains(t, got, `<a class="btn ref" href="/demo/refs" data-fire="refs"`) | |||
| 60 | + | assert.NotContains(t, got, "hx-on:click", "no handler is inlined in the header") | |||
| 61 | + | for _, want := range []string{`class="btn search" data-js`, `class="btn icon" data-js data-fire="help"`} { | |||
| 62 | + | assert.Contains(t, got, want) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Data reaches the scripts through data-* attributes, never spliced into an | |||
| 67 | + | // attribute that is evaluated. | |||
| 68 | + | func TestDiffControlsPassDataThroughAttributes(t *testing.T) { | |||
| 69 | + | t.Parallel() | |||
| 70 | + | files := renderToString(t, FileList([]DiffFile{{ID: "f1'x", Path: "a.go", Status: "M"}})) | |||
| 71 | + | assert.NotContains(t, files, ":aria-current", "the file in view is marked by ermite.js") | |||
| 72 | + | assert.NotContains(t, files, "f1'x'", "no id is spliced into a script") | |||
| 73 | + | ||||
| 74 | + | link := renderToString(t, viewLink(DiffView{Both: true, View: "split"}, "split", "Split", "d", "?view=split")) | |||
| 75 | + | assert.Contains(t, link, `data-fire="split"`) | |||
| 76 | + | assert.Contains(t, link, `this.dataset.view`) | |||
| 77 | + | assert.NotContains(t, link, "=== 'split'") | |||
| 78 | + | } | |||
A internal/adapters/web/helpers_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // testPublicURL and testSourceURL are the instance of the servers the tests | |||
| 18 | + | // build. | |||
| 19 | + | const ( | |||
| 20 | + | testPublicURL = "https://forge.example" | |||
| 21 | + | testSourceURL = "https://forge.example/ermite" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // repoName parses a repository name the test knows is valid. | |||
| 25 | + | func repoName(t testing.TB, s string) domain.RepoName { | |||
| 26 | + | t.Helper() | |||
| 27 | + | n, err := domain.ParseRepoName(s) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | return n | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // objectID parses a commit id the test knows is valid. | |||
| 33 | + | func objectID(t testing.TB, s string) domain.ObjectID { | |||
| 34 | + | t.Helper() | |||
| 35 | + | id, err := domain.ParseObjectID(s) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | return id | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // publicRepo is the repository the use cases of the tests answer with: | |||
| 41 | + | // public, with main as its default branch. | |||
| 42 | + | func publicRepo(t testing.TB, name string) ports.RepoView { | |||
| 43 | + | t.Helper() | |||
| 44 | + | return ports.RepoView{ | |||
| 45 | + | Repo: domain.Repo{Name: repoName(t, name), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}, | |||
| 46 | + | DefaultBranch: "main", | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // reposFailing is a RepoService whose reads fail with err, whatever they ask. | |||
| 51 | + | func reposFailing(t testing.TB, err error) *mocks.RepoService { | |||
| 52 | + | t.Helper() | |||
| 53 | + | m := mocks.NewRepoService(t) | |||
| 54 | + | m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, err).Maybe() | |||
| 55 | + | m.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, err).Maybe() | |||
| 56 | + | return m | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // reposServing is a RepoService that knows the repositories, by name, as seen | |||
| 60 | + | // by every caller. | |||
| 61 | + | func reposServing(t testing.TB, views ...ports.RepoView) *mocks.RepoService { | |||
| 62 | + | t.Helper() | |||
| 63 | + | m := mocks.NewRepoService(t) | |||
| 64 | + | byName := map[string]ports.RepoView{} | |||
| 65 | + | for _, v := range views { | |||
| 66 | + | byName[v.Name.String()] = v | |||
| 67 | + | } | |||
| 68 | + | m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 69 | + | func(_ context.Context, _ domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 70 | + | v, ok := byName[name.String()] | |||
| 71 | + | if !ok { | |||
| 72 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 73 | + | } | |||
| 74 | + | return v.Repo, nil | |||
| 75 | + | }).Maybe() | |||
| 76 | + | m.EXPECT().View(mock.Anything, mock.Anything).RunAndReturn( | |||
| 77 | + | func(_ context.Context, r domain.Repo) (ports.RepoView, error) { | |||
| 78 | + | v, ok := byName[r.Name.String()] | |||
| 79 | + | if !ok { | |||
| 80 | + | return ports.RepoView{}, domain.ErrNotFound | |||
| 81 | + | } | |||
| 82 | + | return v, nil | |||
| 83 | + | }).Maybe() | |||
| 84 | + | m.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 85 | + | func(context.Context, domain.Principal, int, string) (ports.RepoPage, error) { | |||
| 86 | + | var page ports.RepoPage | |||
| 87 | + | for _, v := range views { | |||
| 88 | + | page.Repos = append(page.Repos, v.Repo) | |||
| 89 | + | } | |||
| 90 | + | return page, nil | |||
| 91 | + | }).Maybe() | |||
| 92 | + | return m | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // newTestServer builds a Server from cfg, with the instance filled in. | |||
| 96 | + | func newTestServer(t testing.TB, cfg Config) *Server { | |||
| 97 | + | t.Helper() | |||
| 98 | + | if cfg.PublicURL == "" { | |||
| 99 | + | cfg.PublicURL = testPublicURL | |||
| 100 | + | } | |||
| 101 | + | if cfg.SourceURL == "" { | |||
| 102 | + | cfg.SourceURL = testSourceURL | |||
| 103 | + | } | |||
| 104 | + | if cfg.Version == "" { | |||
| 105 | + | cfg.Version = "v1" | |||
| 106 | + | } | |||
| 107 | + | srv, err := New(cfg) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | return srv | |||
| 110 | + | } | |||
M internal/adapters/web/highlight.go +24 -6
| @@ -3,6 +3,7 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "bytes" | |||
| 6 | "context" | 7 | "context" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "html" | 9 | "html" | ||
| @@ -109,7 +110,7 @@ const maxViewLines = 20000 | |||||
| 109 | 110 | ||||
| 110 | // countLines is the number of lines of src, as the code view numbers them. | 111 | // countLines is the number of lines of src, as the code view numbers them. | ||
| 111 | func countLines(src []byte) int { | 112 | func countLines(src []byte) int { | ||
| 112 | - | n := strings.Count(string(src), "\n") | 113 | + | n := bytes.Count(src, []byte{'\n'}) |
| 113 | if len(src) > 0 && src[len(src)-1] != '\n' { | 114 | if len(src) > 0 && src[len(src)-1] != '\n' { | ||
| 114 | n++ | 115 | n++ | ||
| 115 | } | 116 | } | ||
| @@ -158,18 +159,35 @@ func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.T | |||||
| 158 | } | 159 | } | ||
| 159 | toks = append(toks, t) | 160 | toks = append(toks, t) | ||
| 160 | } | 161 | } | ||
| 161 | - | if strings.TrimRight(joinTokens(toks), "\n") != strings.TrimRight(src, "\n") { | 162 | + | if !tokensMatch(toks, src) { |
| 162 | return nil, false | 163 | return nil, false | ||
| 163 | } | 164 | } | ||
| 164 | return toks, true | 165 | return toks, true | ||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 167 | - | func joinTokens(toks []chroma.Token) string { | 168 | + | // tokensMatch reports whether the values of the tokens, joined, are src but |
| 168 | - | var b strings.Builder | 169 | + | // for the line feeds that end either: it compares piece by piece, where |
| 170 | + | // joining the tokens first would copy the whole file. | |||
| 171 | + | func tokensMatch(toks []chroma.Token, src string) bool { | |||
| 172 | + | want := strings.TrimRight(src, "\n") | |||
| 173 | + | pos := 0 | |||
| 169 | for _, t := range toks { | 174 | for _, t := range toks { | ||
| 170 | - | b.WriteString(t.Value) | 175 | + | v := t.Value |
| 176 | + | rest := want[pos:] | |||
| 177 | + | if len(v) <= len(rest) { | |||
| 178 | + | if rest[:len(v)] != v { | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | pos += len(v) | |||
| 182 | + | continue | |||
| 183 | + | } | |||
| 184 | + | // The token reaches past the end of the source: only line feeds may. | |||
| 185 | + | if v[:len(rest)] != rest || strings.Trim(v[len(rest):], "\n") != "" { | |||
| 186 | + | return false | |||
| 187 | + | } | |||
| 188 | + | pos = len(want) | |||
| 171 | } | 189 | } | ||
| 172 | - | return b.String() | 190 | + | return pos == len(want) |
| 173 | } | 191 | } | ||
| 174 | 192 | ||||
| 175 | // splitSource splits plain text into lines, without the empty element after | 193 | // splitSource splits plain text into lines, without the empty element after | ||
M internal/adapters/web/highlight_test.go +50 -5
| @@ -11,10 +11,11 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/domain" |
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | func TestHighlightLines(t *testing.T) { | 17 | func TestHighlightLines(t *testing.T) { | ||
| 18 | + | t.Parallel() | |||
| 18 | v := highlight(context.Background(), "a.go", "package main\n\nfunc main() {}\n") | 19 | v := highlight(context.Background(), "a.go", "package main\n\nfunc main() {}\n") | ||
| 19 | assert.Equal(t, 3, v.lines) | 20 | assert.Equal(t, 3, v.lines) | ||
| 20 | assert.Equal(t, "Go", v.lang) | 21 | assert.Equal(t, "Go", v.lang) | ||
| @@ -30,6 +31,7 @@ func TestHighlightLines(t *testing.T) { | |||||
| 30 | } | 31 | } | ||
| 31 | 32 | ||||
| 32 | func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) { | 33 | func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) { | ||
| 34 | + | t.Parallel() | |||
| 33 | v := highlight(context.Background(), "unknown.zzz", "<script>alert(1)</script> & \"q\"\n") | 35 | v := highlight(context.Background(), "unknown.zzz", "<script>alert(1)</script> & \"q\"\n") | ||
| 34 | assert.Equal(t, "", v.lang) | 36 | assert.Equal(t, "", v.lang) | ||
| 35 | h := string(v.html) | 37 | h := string(v.html) | ||
| @@ -41,6 +43,7 @@ func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) { | |||||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| 43 | func TestHighlightMarksInvisibleCharacters(t *testing.T) { | 45 | func TestHighlightMarksInvisibleCharacters(t *testing.T) { | ||
| 46 | + | t.Parallel() | |||
| 44 | for r, cp := range map[rune]string{ | 47 | for r, cp := range map[rune]string{ | ||
| 45 | 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF", | 48 | 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF", | ||
| 46 | 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F", | 49 | 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F", | ||
| @@ -63,6 +66,7 @@ func TestHighlightMarksInvisibleCharacters(t *testing.T) { | |||||
| 63 | // The lexers do not lose or add characters: the text of the lines is the | 66 | // The lexers do not lose or add characters: the text of the lines is the | ||
| 64 | // source, whatever the language. | 67 | // source, whatever the language. | ||
| 65 | func TestHighlightKeepsTheSource(t *testing.T) { | 68 | func TestHighlightKeepsTheSource(t *testing.T) { | ||
| 69 | + | t.Parallel() | |||
| 66 | srcs := map[string]string{ | 70 | srcs := map[string]string{ | ||
| 67 | "a.go": "package a\n\nvar s = `multi\nline`\n// end", | 71 | "a.go": "package a\n\nvar s = `multi\nline`\n// end", | ||
| 68 | "b.py": "def f():\n '''doc\n string'''\n return 1\n", | 72 | "b.py": "def f():\n '''doc\n string'''\n return 1\n", | ||
| @@ -93,6 +97,7 @@ func TestHighlightKeepsTheSource(t *testing.T) { | |||||
| 93 | } | 97 | } | ||
| 94 | 98 | ||||
| 95 | func TestFirstLinesAndCount(t *testing.T) { | 99 | func TestFirstLinesAndCount(t *testing.T) { | ||
| 100 | + | t.Parallel() | |||
| 96 | assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2)) | 101 | assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2)) | ||
| 97 | assert.Equal(t, "a\nb", firstLines("a\nb", 5)) | 102 | assert.Equal(t, "a\nb", firstLines("a\nb", 5)) | ||
| 98 | assert.Equal(t, 3, countLines([]byte("a\nb\nc"))) | 103 | assert.Equal(t, 3, countLines([]byte("a\nb\nc"))) | ||
| @@ -101,6 +106,7 @@ func TestFirstLinesAndCount(t *testing.T) { | |||||
| 101 | } | 106 | } | ||
| 102 | 107 | ||||
| 103 | func TestHighlightCtxCancelled(t *testing.T) { | 108 | func TestHighlightCtxCancelled(t *testing.T) { | ||
| 109 | + | t.Parallel() | |||
| 104 | ctx, cancel := context.WithCancel(context.Background()) | 110 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 105 | cancel() | 111 | cancel() | ||
| 106 | v := highlightCtx(ctx, "a.go", "package a\n") | 112 | v := highlightCtx(ctx, "a.go", "package a\n") | ||
| @@ -116,12 +122,14 @@ func (panicLexer) Tokenise(*chroma.TokeniseOptions, string) (chroma.Iterator, er | |||||
| 116 | } | 122 | } | ||
| 117 | 123 | ||||
| 118 | func TestTokeniseRecoversFromALexerPanic(t *testing.T) { | 124 | func TestTokeniseRecoversFromALexerPanic(t *testing.T) { | ||
| 125 | + | t.Parallel() | |||
| 119 | toks, ok := tokenise(context.Background(), panicLexer{}, "x\n") | 126 | toks, ok := tokenise(context.Background(), panicLexer{}, "x\n") | ||
| 120 | assert.False(t, ok) | 127 | assert.False(t, ok) | ||
| 121 | assert.Nil(t, toks) | 128 | assert.Nil(t, toks) | ||
| 122 | } | 129 | } | ||
| 123 | 130 | ||||
| 124 | func TestSniffImage(t *testing.T) { | 131 | func TestSniffImage(t *testing.T) { | ||
| 132 | + | t.Parallel() | |||
| 125 | assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"))) | 133 | assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"))) | ||
| 126 | assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a...."))) | 134 | assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a...."))) | ||
| 127 | assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF"))) | 135 | assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF"))) | ||
| @@ -132,12 +140,13 @@ func TestSniffImage(t *testing.T) { | |||||
| 132 | } | 140 | } | ||
| 133 | 141 | ||||
| 134 | func TestLocate(t *testing.T) { | 142 | func TestLocate(t *testing.T) { | ||
| 135 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | 143 | + | t.Parallel() |
| 144 | + | b, tag := domain.RefKindBranch, domain.RefKindTag | |||
| 136 | d := &repoData{ | 145 | d := &repoData{ | ||
| 137 | - | branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, | 146 | + | branches: []domain.Ref{ref(b, "main"), ref(b, "feature/x")}, |
| 138 | - | tags: []*pb.Ref{ref(tag, "v1/rc")}, | 147 | + | tags: []domain.Ref{ref(tag, "v1/rc")}, |
| 139 | } | 148 | } | ||
| 140 | - | d.head = d.branches[0] | 149 | + | d.head = &d.branches[0] |
| 141 | for _, tt := range []struct { | 150 | for _, tt := range []struct { | ||
| 142 | in, name, path string | 151 | in, name, path string | ||
| 143 | detached, ok bool | 152 | detached, ok bool | ||
| @@ -169,6 +178,7 @@ func TestLocate(t *testing.T) { | |||||
| 169 | } | 178 | } | ||
| 170 | 179 | ||||
| 171 | func TestFileCrumbs(t *testing.T) { | 180 | func TestFileCrumbs(t *testing.T) { | ||
| 181 | + | t.Parallel() | |||
| 172 | cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"}) | 182 | cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"}) | ||
| 173 | require.Len(t, cs, 5) | 183 | require.Len(t, cs, 5) | ||
| 174 | assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href) | 184 | assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href) | ||
| @@ -218,3 +228,38 @@ func normSpace(s string) string { | |||||
| 218 | } | 228 | } | ||
| 219 | return strings.Join(out, "\n") | 229 | return strings.Join(out, "\n") | ||
| 220 | } | 230 | } | ||
| 231 | + | ||||
| 232 | + | func TestTokensMatch(t *testing.T) { | |||
| 233 | + | t.Parallel() | |||
| 234 | + | toks := func(vs ...string) []chroma.Token { | |||
| 235 | + | var out []chroma.Token | |||
| 236 | + | for _, v := range vs { | |||
| 237 | + | out = append(out, chroma.Token{Type: chroma.Text, Value: v}) | |||
| 238 | + | } | |||
| 239 | + | return out | |||
| 240 | + | } | |||
| 241 | + | for _, tt := range []struct { | |||
| 242 | + | name string | |||
| 243 | + | toks []chroma.Token | |||
| 244 | + | src string | |||
| 245 | + | want bool | |||
| 246 | + | }{ | |||
| 247 | + | {"same pieces", toks("ab", "c\n", "d"), "abc\nd", true}, | |||
| 248 | + | {"lexer added a final line feed", toks("a\n", "b", "\n"), "a\nb", true}, | |||
| 249 | + | {"source ends with line feeds", toks("a", "\n"), "a\n\n", true}, | |||
| 250 | + | {"empty", nil, "", true}, | |||
| 251 | + | {"empty tokens", toks("", ""), "\n", true}, | |||
| 252 | + | {"only line feeds", toks("\n\n"), "", true}, | |||
| 253 | + | {"token differs", toks("a", "x"), "ab", false}, | |||
| 254 | + | {"tokens stop short", toks("a"), "ab", false}, | |||
| 255 | + | {"tokens go past the source", toks("abc"), "ab", false}, | |||
| 256 | + | {"extra text after the source", toks("ab", "\nx"), "ab", false}, | |||
| 257 | + | {"lexer dropped a line", toks("a\n", "c"), "a\nb\nc", false}, | |||
| 258 | + | {"unicode", toks("é", "日本"), "é日本", true}, | |||
| 259 | + | } { | |||
| 260 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 261 | + | t.Parallel() | |||
| 262 | + | assert.Equal(t, tt.want, tokensMatch(tt.toks, tt.src)) | |||
| 263 | + | }) | |||
| 264 | + | } | |||
| 265 | + | } | |||
M internal/adapters/web/home.go +33 -24
| @@ -9,30 +9,39 @@ import ( | |||||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | - | "connectrpc.com/connect" | |||
| 13 | "github.com/a-h/templ" | 12 | "github.com/a-h/templ" | ||
| 14 | 13 | ||||
| 15 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/ports" |
| 16 | ) | 15 | ) | ||
| 17 | 16 | ||||
| 18 | // maxRepos bounds the repositories one page lists. | 17 | // maxRepos bounds the repositories one page lists. | ||
| 19 | const maxRepos = 1000 | 18 | const maxRepos = 1000 | ||
| 20 | 19 | ||||
| 21 | - | // pageSize is the page size asked from the list RPCs: the largest the API | 20 | + | // pageSize is the page size asked from the commit lists: the largest the use |
| 22 | - | // accepts. | 21 | + | // case accepts for them. |
| 23 | const pageSize = 100 | 22 | const pageSize = 100 | ||
| 24 | 23 | ||||
| 25 | // listRepos reads the repositories the visitor may see, ordered by name. | 24 | // listRepos reads the repositories the visitor may see, ordered by name. | ||
| 26 | - | func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) { | 25 | + | // Every page of ListRepos scans the whole repository list and reads the HEAD |
| 27 | - | var repos []*pb.Repo | 26 | + | // of each repository of the page, so the list is asked in one page of up to |
| 27 | + | // maxRepos: one scan in the common case, whatever the cap of the use case is | |||
| 28 | + | // (a lower one only means more pages, followed by their token). | |||
| 29 | + | func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) { | |||
| 30 | + | var repos []ports.RepoView | |||
| 28 | token := "" | 31 | token := "" | ||
| 29 | for len(repos) < maxRepos { | 32 | for len(repos) < maxRepos { | ||
| 30 | - | res, err := s.cfg.Repos.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: pageSize, PageToken: token})) | 33 | + | page, err := s.cfg.Repos.ListPage(ctx, principal(ctx), maxRepos, token) |
| 31 | if err != nil { | 34 | if err != nil { | ||
| 32 | return nil, err | 35 | return nil, err | ||
| 33 | } | 36 | } | ||
| 34 | - | repos = append(repos, res.Msg.GetRepos()...) | 37 | + | for _, repo := range page.Repos { |
| 35 | - | if token = res.Msg.GetNextPageToken(); token == "" { | 38 | + | view, err := s.cfg.Repos.View(ctx, repo) |
| 39 | + | if err != nil { | |||
| 40 | + | return nil, err | |||
| 41 | + | } | |||
| 42 | + | repos = append(repos, view) | |||
| 43 | + | } | |||
| 44 | + | if token = page.NextPageToken; token == "" { | |||
| 36 | break | 45 | break | ||
| 37 | } | 46 | } | ||
| 38 | } | 47 | } | ||
| @@ -48,17 +57,17 @@ var homeCols = []struct{ key, label, class string }{ | |||||
| 48 | } | 57 | } | ||
| 49 | 58 | ||||
| 50 | // sortRepos orders repos by the column key; ties keep the order by name. | 59 | // sortRepos orders repos by the column key; ties keep the order by name. | ||
| 51 | - | func sortRepos(repos []*pb.Repo, key string, desc bool) { | 60 | + | func sortRepos(repos []ports.RepoView, key string, desc bool) { |
| 52 | - | less := func(a, b *pb.Repo) bool { | 61 | + | less := func(a, b ports.RepoView) bool { |
| 53 | switch key { | 62 | switch key { | ||
| 54 | case "desc": | 63 | case "desc": | ||
| 55 | - | return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription()) | 64 | + | return strings.ToLower(a.Description) < strings.ToLower(b.Description) |
| 56 | case "branch": | 65 | case "branch": | ||
| 57 | - | return a.GetDefaultBranch() < b.GetDefaultBranch() | 66 | + | return a.DefaultBranch < b.DefaultBranch |
| 58 | case "push": | 67 | case "push": | ||
| 59 | - | return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime()) | 68 | + | return a.PushedAt.Before(b.PushedAt) |
| 60 | } | 69 | } | ||
| 61 | - | return a.GetName() < b.GetName() | 70 | + | return a.Name.String() < b.Name.String() |
| 62 | } | 71 | } | ||
| 63 | sort.SliceStable(repos, func(i, j int) bool { | 72 | sort.SliceStable(repos, func(i, j int) bool { | ||
| 64 | if desc { | 73 | if desc { | ||
| @@ -114,25 +123,25 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 114 | } | 123 | } | ||
| 115 | 124 | ||||
| 116 | // homeRow is the row of a repository in the home list. | 125 | // homeRow is the row of a repository in the home list. | ||
| 117 | - | func homeRow(r *pb.Repo, now time.Time, cursor bool) HomeRow { | 126 | + | func homeRow(r ports.RepoView, now time.Time, cursor bool) HomeRow { |
| 127 | + | name := r.Name.String() | |||
| 118 | row := HomeRow{ | 128 | row := HomeRow{ | ||
| 119 | - | ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()), | 129 | + | ID: "repo-" + name, Name: name, Href: repoHref(name), |
| 120 | - | Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor, | 130 | + | Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor, |
| 121 | } | 131 | } | ||
| 122 | - | if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 { | 132 | + | if !r.PushedAt.IsZero() { |
| 123 | - | t := r.GetPushedAt().AsTime() | 133 | + | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) |
| 124 | - | row.Pushed, row.PushedAt = ago(now, t), rfc3339(t) | |||
| 125 | } | 134 | } | ||
| 126 | return row | 135 | return row | ||
| 127 | } | 136 | } | ||
| 128 | 137 | ||||
| 129 | // repoCmds are the repositories as targets of the ":" command line, but the | 138 | // repoCmds are the repositories as targets of the ":" command line, but the | ||
| 130 | // one being viewed. | 139 | // one being viewed. | ||
| 131 | - | func repoCmds(repos []*pb.Repo, except string) []CmdItem { | 140 | + | func repoCmds(repos []ports.RepoView, except string) []CmdItem { |
| 132 | var out []CmdItem | 141 | var out []CmdItem | ||
| 133 | for _, r := range repos { | 142 | for _, r := range repos { | ||
| 134 | - | if r.GetName() != except { | 143 | + | if name := r.Name.String(); name != except { |
| 135 | - | out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())}) | 144 | + | out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)}) |
| 136 | } | 145 | } | ||
| 137 | } | 146 | } | ||
| 138 | return out | 147 | return out | ||
M internal/adapters/web/keys.templ +23 -63
| @@ -3,72 +3,32 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | // Keys is the keyboard map. ermite.js turns keys into k-<name> events on | 5 | // Keys is the keyboard map. ermite.js turns keys into k-<name> events on | ||
| 6 | - | // <body>; this element (hx-on + hx-live helpers) is the only place that | 6 | + | // <body>; this element is the only place that binds them, each to one call of |
| 7 | - | // reacts to them. | 7 | + | // window.ermite (ermite.js), which does the work. |
| 8 | templ Keys() { | 8 | templ Keys() { | ||
| 9 | <div | 9 | <div | ||
| 10 | id="keys" | 10 | id="keys" | ||
| 11 | hidden | 11 | hidden | ||
| 12 | - | hx-on="k-j from:body -> (async (d) => { | 12 | + | hx-on="k-j from:body -> ermite.move(1); |
| 13 | - | if (q('dialog[open]').count) return; | 13 | + | k-k from:body -> ermite.move(-1); |
| 14 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 14 | + | k-top from:body -> ermite.edge(false); |
| 15 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 15 | + | k-bottom from:body -> ermite.edge(true); |
| 16 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 16 | + | k-open from:body -> ermite.open(); |
| 17 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 17 | + | k-esc from:body -> ermite.closeDialogs(); |
| 18 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 18 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 19 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 19 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 20 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 20 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 21 | - | })(1); | 21 | + | k-filter from:body -> ermite.focusFilter(); |
| 22 | - | k-k from:body -> (async (d) => { | 22 | + | k-gh from:body -> ermite.go('.brand'); |
| 23 | - | if (q('dialog[open]').count) return; | 23 | + | k-s from:body -> ermite.tab('s'); |
| 24 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 24 | + | k-t from:body -> ermite.tab('t'); |
| 25 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 25 | + | k-c from:body -> ermite.tab('c'); |
| 26 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 26 | + | k-r from:body -> ermite.tab('r'); |
| 27 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 27 | + | k-m from:body -> ermite.tab('m'); |
| 28 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 28 | + | k-next from:body -> ermite.nextNote(); |
| 29 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 29 | + | k-split from:body -> ermite.layout('split'); |
| 30 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 30 | + | k-unified from:body -> ermite.layout('unified'); |
| 31 | - | })(-1); | 31 | + | k-collapse from:body -> ermite.collapse(); |
| 32 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 32 | + | k-permalink from:body -> ermite.permalink()" |
| 33 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 34 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 35 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 36 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 37 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 38 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 39 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 40 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 41 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 42 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 43 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 44 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 45 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 46 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 47 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 48 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 49 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 50 | - | k-next from:body -> { | |||
| 51 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 52 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 53 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 54 | - | }; | |||
| 55 | - | k-split from:body -> { | |||
| 56 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 57 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 58 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 59 | - | }; | |||
| 60 | - | k-unified from:body -> { | |||
| 61 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 62 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 63 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 64 | - | }; | |||
| 65 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 66 | - | k-permalink from:body -> { | |||
| 67 | - | let b = document.body.dataset; | |||
| 68 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 69 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 70 | - | navigator.clipboard?.writeText(location.href); | |||
| 71 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 72 | - | }" | |||
| 73 | ></div> | 33 | ></div> | ||
| 74 | } | 34 | } | ||
M internal/adapters/web/keys_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/markdown_test.go +6 -0
| @@ -19,6 +19,7 @@ func renderMD(t *testing.T, src string) (string, []Heading) { | |||||
| 19 | 19 | ||||
| 20 | // AC4: raw HTML, javascript: links and SVG are neutralised. | 20 | // AC4: raw HTML, javascript: links and SVG are neutralised. | ||
| 21 | func TestMarkdownNeutralisation(t *testing.T) { | 21 | func TestMarkdownNeutralisation(t *testing.T) { | ||
| 22 | + | t.Parallel() | |||
| 22 | tests := []struct { | 23 | tests := []struct { | ||
| 23 | name, src string | 24 | name, src string | ||
| 24 | absent []string | 25 | absent []string | ||
| @@ -59,6 +60,7 @@ func TestMarkdownNeutralisation(t *testing.T) { | |||||
| 59 | } | 60 | } | ||
| 60 | for _, tt := range tests { | 61 | for _, tt := range tests { | ||
| 61 | t.Run(tt.name, func(t *testing.T) { | 62 | t.Run(tt.name, func(t *testing.T) { | ||
| 63 | + | t.Parallel() | |||
| 62 | out, _ := renderMD(t, tt.src) | 64 | out, _ := renderMD(t, tt.src) | ||
| 63 | for _, s := range tt.absent { | 65 | for _, s := range tt.absent { | ||
| 64 | assert.NotContains(t, out, s) | 66 | assert.NotContains(t, out, s) | ||
| @@ -80,6 +82,7 @@ func TestMarkdownNeutralisation(t *testing.T) { | |||||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 82 | func TestMarkdownLinks(t *testing.T) { | 84 | func TestMarkdownLinks(t *testing.T) { | ||
| 85 | + | t.Parallel() | |||
| 83 | tests := []struct{ name, src, href string }{ | 86 | tests := []struct{ name, src, href string }{ | ||
| 84 | {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`}, | 87 | {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`}, | ||
| 85 | {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`}, | 88 | {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`}, | ||
| @@ -95,6 +98,7 @@ func TestMarkdownLinks(t *testing.T) { | |||||
| 95 | } | 98 | } | ||
| 96 | for _, tt := range tests { | 99 | for _, tt := range tests { | ||
| 97 | t.Run(tt.name, func(t *testing.T) { | 100 | t.Run(tt.name, func(t *testing.T) { | ||
| 101 | + | t.Parallel() | |||
| 98 | out, _ := renderMD(t, tt.src) | 102 | out, _ := renderMD(t, tt.src) | ||
| 99 | if tt.href == "" { | 103 | if tt.href == "" { | ||
| 100 | assert.NotContains(t, out, "<a ") | 104 | assert.NotContains(t, out, "<a ") | ||
| @@ -106,6 +110,7 @@ func TestMarkdownLinks(t *testing.T) { | |||||
| 106 | } | 110 | } | ||
| 107 | 111 | ||||
| 108 | func TestMarkdownOutline(t *testing.T) { | 112 | func TestMarkdownOutline(t *testing.T) { | ||
| 113 | + | t.Parallel() | |||
| 109 | out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n") | 114 | out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n") | ||
| 110 | assert.Equal(t, []Heading{ | 115 | assert.Equal(t, []Heading{ | ||
| 111 | {Level: 1, ID: "md-title", Text: "Title"}, | 116 | {Level: 1, ID: "md-title", Text: "Title"}, | ||
| @@ -124,6 +129,7 @@ func TestMarkdownOutline(t *testing.T) { | |||||
| 124 | } | 129 | } | ||
| 125 | 130 | ||||
| 126 | func TestMarkdownGFM(t *testing.T) { | 131 | func TestMarkdownGFM(t *testing.T) { | ||
| 132 | + | t.Parallel() | |||
| 127 | out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n") | 133 | out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n") | ||
| 128 | assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style") | 134 | assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style") | ||
| 129 | assert.NotContains(t, out, "style=") | 135 | assert.NotContains(t, out, "style=") | ||
M internal/adapters/web/mr.go +111 -116
| @@ -11,14 +11,14 @@ import ( | |||||
| 11 | "strconv" | 11 | "strconv" | ||
| 12 | "strings" | 12 | "strings" | ||
| 13 | 13 | ||||
| 14 | - | "connectrpc.com/connect" | |||
| 15 | "github.com/a-h/templ" | 14 | "github.com/a-h/templ" | ||
| 16 | 15 | ||||
| 17 | - | pb "bnema.dev/ermite-api/gen/ermite" | 16 | + | "bnema.dev/ermite/internal/domain" |
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | // The merge request pages (ADR 0017). They read through MergeRequestService | 20 | // The merge request pages (ADR 0017). They read through MergeRequestService | ||
| 21 | - | // and GitService as the visitor of the request: anonymous sees the merge | 21 | + | // and BrowseService as the visitor of the request: anonymous sees the merge |
| 22 | // requests of public repositories, the signed-in owner those of every | 22 | // requests of public repositories, the signed-in owner those of every | ||
| 23 | // repository, and only the owner gets the forms (mr_post.go handles them). | 23 | // repository, and only the owner gets the forms (mr_post.go handles them). | ||
| 24 | // | 24 | // | ||
| @@ -44,9 +44,9 @@ import ( | |||||
| 44 | 44 | ||||
| 45 | const ( | 45 | const ( | ||
| 46 | // mrPerPage is how many decided merge requests a page of the list shows, | 46 | // mrPerPage is how many decided merge requests a page of the list shows, | ||
| 47 | - | // at least; pages of the API are read whole. | 47 | + | // at least; pages of the use case are read whole. |
| 48 | mrPerPage = 50 | 48 | mrPerPage = 50 | ||
| 49 | - | // maxMRPages bounds the API pages one list page reads. | 49 | + | // maxMRPages bounds the pages of the use case one list page reads. |
| 50 | maxMRPages = 20 | 50 | maxMRPages = 20 | ||
| 51 | // maxOpenPages bounds the pages read for the open merge requests, which | 51 | // maxOpenPages bounds the pages read for the open merge requests, which | ||
| 52 | // the first list page shows in full. | 52 | // the first list page shows in full. | ||
| @@ -260,39 +260,39 @@ func mrNumber(r *http.Request) (int64, error) { | |||||
| 260 | return n, nil | 260 | return n, nil | ||
| 261 | } | 261 | } | ||
| 262 | 262 | ||||
| 263 | - | func stateName(s pb.MergeRequestState) string { | 263 | + | func stateName(s domain.MRState) string { |
| 264 | switch s { | 264 | switch s { | ||
| 265 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN: | 265 | + | case domain.MRStateOpen: |
| 266 | return "open" | 266 | return "open" | ||
| 267 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED: | 267 | + | case domain.MRStateMerged: |
| 268 | return "merged" | 268 | return "merged" | ||
| 269 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED: | 269 | + | case domain.MRStateClosed: |
| 270 | return "closed" | 270 | return "closed" | ||
| 271 | } | 271 | } | ||
| 272 | return "unknown" | 272 | return "unknown" | ||
| 273 | } | 273 | } | ||
| 274 | 274 | ||||
| 275 | - | func modeName(m pb.MergeMode) string { | 275 | + | func modeName(m domain.MergeMode) string { |
| 276 | switch m { | 276 | switch m { | ||
| 277 | - | case pb.MergeMode_MERGE_MODE_FAST_FORWARD: | 277 | + | case domain.MergeModeFastForward: |
| 278 | return "fast-forward" | 278 | return "fast-forward" | ||
| 279 | - | case pb.MergeMode_MERGE_MODE_SQUASH: | 279 | + | case domain.MergeModeSquash: |
| 280 | return "squash" | 280 | return "squash" | ||
| 281 | } | 281 | } | ||
| 282 | return "" | 282 | return "" | ||
| 283 | } | 283 | } | ||
| 284 | 284 | ||||
| 285 | // mrRow converts a merge request of a listing. | 285 | // mrRow converts a merge request of a listing. | ||
| 286 | - | func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow { | 286 | + | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { |
| 287 | row := MRRow{ | 287 | row := MRRow{ | ||
| 288 | - | Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()), | 288 | + | Number: "!" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number), |
| 289 | - | Title: visibleText(m.GetTitle()), State: stateName(m.GetState()), | 289 | + | Title: visibleText(m.Title.String()), State: stateName(m.State), |
| 290 | - | Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()), | 290 | + | Branches: visibleText(m.Source) + " → " + visibleText(m.Target), |
| 291 | - | Author: visibleText(m.GetAuthor()), | 291 | + | Author: visibleText(m.Author), |
| 292 | } | 292 | } | ||
| 293 | - | ts := m.GetUpdatedAt() | 293 | + | ts := m.UpdatedAt |
| 294 | - | if !ts.IsValid() { | 294 | + | if ts.IsZero() { |
| 295 | - | ts = m.GetCreatedAt() | 295 | + | ts = m.CreatedAt |
| 296 | } | 296 | } | ||
| 297 | row.When, row.WhenAt = whenOf(s.cfg.Now(), ts) | 297 | row.When, row.WhenAt = whenOf(s.cfg.Now(), ts) | ||
| 298 | return row | 298 | return row | ||
| @@ -300,30 +300,30 @@ func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow { | |||||
| 300 | 300 | ||||
| 301 | // mrList lists the merge requests: the open ones, then the merged and closed | 301 | // mrList lists the merge requests: the open ones, then the merged and closed | ||
| 302 | // ones, a page at a time. The decided ones are paged with the page token of | 302 | // ones, a page at a time. The decided ones are paged with the page token of | ||
| 303 | - | // the API in "page"; the open ones show on the first page only. | 303 | + | // the use case in "page"; the open ones show on the first page only. |
| 304 | func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 304 | func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 305 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 305 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 306 | if err != nil { | 306 | if err != nil { | ||
| 307 | return nil, 0, err | 307 | return nil, 0, err | ||
| 308 | } | 308 | } | ||
| 309 | token := r.URL.Query().Get("page") | 309 | token := r.URL.Query().Get("page") | ||
| 310 | - | p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())} | 310 | + | p := MRListPage{Default: visibleText(d.repo.DefaultBranch)} |
| 311 | p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}} | 311 | p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}} | ||
| 312 | base := mrBase(d.name()) | 312 | base := mrBase(d.name()) | ||
| 313 | 313 | ||||
| 314 | if token == "" { | 314 | if token == "" { | ||
| 315 | tok := "" | 315 | tok := "" | ||
| 316 | for i := 0; i < maxOpenPages; i++ { | 316 | for i := 0; i < maxOpenPages; i++ { | ||
| 317 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | 317 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{ |
| 318 | - | Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok, | 318 | + | State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok, |
| 319 | - | })) | 319 | + | }) |
| 320 | if err != nil { | 320 | if err != nil { | ||
| 321 | return nil, 0, err | 321 | return nil, 0, err | ||
| 322 | } | 322 | } | ||
| 323 | - | for _, m := range res.Msg.GetMergeRequests() { | 323 | + | for _, m := range res.MergeRequests { |
| 324 | p.Open = append(p.Open, s.mrRow(d.name(), m)) | 324 | p.Open = append(p.Open, s.mrRow(d.name(), m)) | ||
| 325 | } | 325 | } | ||
| 326 | - | if tok = res.Msg.GetNextPageToken(); tok == "" { | 326 | + | if tok = res.NextPageToken; tok == "" { |
| 327 | break | 327 | break | ||
| 328 | } | 328 | } | ||
| 329 | } | 329 | } | ||
| @@ -334,18 +334,18 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 334 | 334 | ||||
| 335 | tok := token | 335 | tok := token | ||
| 336 | for i := 0; i < maxMRPages; i++ { | 336 | for i := 0; i < maxMRPages; i++ { | ||
| 337 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | 337 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{ |
| 338 | - | Repo: d.name(), PageSize: pageSize, PageToken: tok, | 338 | + | PageSize: pageSize, PageToken: tok, |
| 339 | - | })) | 339 | + | }) |
| 340 | if err != nil { | 340 | if err != nil { | ||
| 341 | return nil, 0, err | 341 | return nil, 0, err | ||
| 342 | } | 342 | } | ||
| 343 | - | for _, m := range res.Msg.GetMergeRequests() { | 343 | + | for _, m := range res.MergeRequests { |
| 344 | - | if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | 344 | + | if m.State != domain.MRStateOpen { |
| 345 | p.Done = append(p.Done, s.mrRow(d.name(), m)) | 345 | p.Done = append(p.Done, s.mrRow(d.name(), m)) | ||
| 346 | } | 346 | } | ||
| 347 | } | 347 | } | ||
| 348 | - | tok = res.Msg.GetNextPageToken() | 348 | + | tok = res.NextPageToken |
| 349 | if tok == "" || len(p.Done) >= mrPerPage { | 349 | if tok == "" || len(p.Done) >= mrPerPage { | ||
| 350 | break | 350 | break | ||
| 351 | } | 351 | } | ||
| @@ -363,10 +363,10 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 363 | p.Done[0].Cursor = true | 363 | p.Done[0].Cursor = true | ||
| 364 | } | 364 | } | ||
| 365 | for _, b := range d.branches { | 365 | for _, b := range d.branches { | ||
| 366 | - | if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches { | 366 | + | if b.ShortName == d.repo.DefaultBranch || len(p.Branches) >= railBranches { |
| 367 | continue | 367 | continue | ||
| 368 | } | 368 | } | ||
| 369 | - | p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())}) | 369 | + | p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.ShortName), Href: repoHref(d.name(), "compare", b.ShortName)}) |
| 370 | } | 370 | } | ||
| 371 | 371 | ||||
| 372 | l := s.repoLayout(ctx, d, "merge-requests") | 372 | l := s.repoLayout(ctx, d, "merge-requests") | ||
| @@ -391,6 +391,7 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 391 | return s.mrPage(ctx, r, true) | 391 | return s.mrPage(ctx, r, true) | ||
| 392 | } | 392 | } | ||
| 393 | 393 | ||||
| 394 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here | |||
| 394 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | 395 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | ||
| 395 | n, err := mrNumber(r) | 396 | n, err := mrNumber(r) | ||
| 396 | if err != nil { | 397 | if err != nil { | ||
| @@ -404,79 +405,72 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 404 | if err != nil { | 405 | if err != nil { | ||
| 405 | return nil, 0, err | 406 | return nil, 0, err | ||
| 406 | } | 407 | } | ||
| 407 | - | res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{ | 408 | + | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since) |
| 408 | - | Repo: d.name(), Number: n, SinceLastRead: since, | |||
| 409 | - | })) | |||
| 410 | if err != nil { | 409 | if err != nil { | ||
| 411 | return nil, 0, err | 410 | return nil, 0, err | ||
| 412 | } | 411 | } | ||
| 413 | - | m := res.Msg.GetMergeRequest() | 412 | + | m := res.MergeRequest |
| 414 | - | if m == nil { | |||
| 415 | - | return nil, 0, errNotFound | |||
| 416 | - | } | |||
| 417 | now := s.cfg.Now() | 413 | now := s.cfg.Now() | ||
| 418 | signed := SignedIn(ctx) | 414 | signed := SignedIn(ctx) | ||
| 419 | - | open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN | 415 | + | open := m.State == domain.MRStateOpen |
| 420 | - | head := m.GetHeadId() | 416 | + | head := m.HeadID.String() |
| 421 | self := mrHref(d.name(), n) | 417 | self := mrHref(d.name(), n) | ||
| 422 | if since { | 418 | if since { | ||
| 423 | self = mrHref(d.name(), n, "since") | 419 | self = mrHref(d.name(), n, "since") | ||
| 424 | } | 420 | } | ||
| 425 | 421 | ||||
| 426 | p := MRPage{ | 422 | p := MRPage{ | ||
| 427 | - | Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()), | 423 | + | Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()), |
| 428 | - | State: stateName(m.GetState()), Open: open, Signed: signed, | 424 | + | State: stateName(m.State), Open: open, Signed: signed, |
| 429 | - | Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()), | 425 | + | Source: visibleText(m.Source), Target: visibleText(m.Target), |
| 430 | - | Author: visibleText(m.GetAuthor()), | 426 | + | Author: visibleText(m.Author), |
| 431 | - | Desc: plainParts(m.GetDescription()), | 427 | + | Desc: plainParts(m.Description.String()), |
| 432 | Head: head, HeadShort: short(head), | 428 | Head: head, HeadShort: short(head), | ||
| 433 | - | NFiles: int(res.Msg.GetStats().GetFilesChanged()), | 429 | + | NFiles: res.Stats.FilesChanged, |
| 434 | - | Adds: int(res.Msg.GetStats().GetAdditions()), | 430 | + | Adds: res.Stats.Additions, |
| 435 | - | Dels: int(res.Msg.GetStats().GetDeletions()), | 431 | + | Dels: res.Stats.Deletions, |
| 436 | - | Files: diffFiles(res.Msg.GetDiff().GetFiles()), | 432 | + | Files: diffFiles(res.Diff.Files), |
| 437 | } | 433 | } | ||
| 438 | p.Opened.Name = p.Author | 434 | p.Opened.Name = p.Author | ||
| 439 | - | p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt()) | 435 | + | p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt) |
| 440 | - | if t := m.GetCreatedAt(); t.IsValid() { | 436 | + | if !m.CreatedAt.IsZero() { |
| 441 | - | p.Opened.Abs = absolute(t.AsTime()) | 437 | + | p.Opened.Abs = absolute(m.CreatedAt) |
| 442 | } | 438 | } | ||
| 443 | - | p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt()) | 439 | + | p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt) |
| 444 | for _, b := range d.branches { | 440 | for _, b := range d.branches { | ||
| 445 | - | switch b.GetShortName() { | 441 | + | switch b.ShortName { |
| 446 | - | case m.GetSourceBranch(): | 442 | + | case m.Source: |
| 447 | - | p.SourceHref = repoHref(d.name(), "tree", b.GetShortName()) | 443 | + | p.SourceHref = repoHref(d.name(), "tree", b.ShortName) |
| 448 | - | case m.GetTargetBranch(): | 444 | + | case m.Target: |
| 449 | - | p.TargetHref = repoHref(d.name(), "tree", b.GetShortName()) | 445 | + | p.TargetHref = repoHref(d.name(), "tree", b.ShortName) |
| 450 | } | 446 | } | ||
| 451 | } | 447 | } | ||
| 452 | - | if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" { | 448 | + | if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() { |
| 453 | - | p.MergedShort = short(m.GetMergedCommitId()) | 449 | + | p.MergedShort = short(m.MergedCommitID.String()) |
| 454 | - | p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId()) | 450 | + | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) |
| 455 | - | p.MergeMode = modeName(m.GetMergeMode()) | 451 | + | p.MergeMode = modeName(m.MergeMode) |
| 456 | } | 452 | } | ||
| 457 | - | if st := d.repo.GetSettings(); st != nil { | 453 | + | for _, mode := range d.repo.Settings.AllowedMergeModes { |
| 458 | - | for _, mode := range st.GetAllowedMergeModes() { | 454 | + | if name := modeName(mode); name != "" { |
| 459 | - | if name := modeName(mode); name != "" { | 455 | + | p.Modes = append(p.Modes, name) |
| 460 | - | p.Modes = append(p.Modes, name) | |||
| 461 | - | } | |||
| 462 | } | 456 | } | ||
| 463 | - | p.ChecksBlock = st.GetFailingChecksBlock() | |||
| 464 | } | 457 | } | ||
| 465 | - | p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles) | 458 | + | p.ChecksBlock = d.repo.Settings.FailingChecksBlock |
| 459 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |||
| 466 | 460 | ||||
| 467 | // The commits of the request, newest first. | 461 | // The commits of the request, newest first. | ||
| 468 | - | for _, c := range res.Msg.GetCommits() { | 462 | + | for _, c := range res.Commits { |
| 469 | row := commitRow(d.name(), c, now) | 463 | row := commitRow(d.name(), c, now) | ||
| 470 | p.Commits = append(p.Commits, MRCommit{ | 464 | p.Commits = append(p.Commits, MRCommit{ | ||
| 471 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | 465 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | ||
| 472 | }) | 466 | }) | ||
| 473 | } | 467 | } | ||
| 474 | - | lastRead := res.Msg.GetLastReadId() | 468 | + | lastRead := res.LastReadID.String() |
| 475 | if signed && lastRead != "" { | 469 | if signed && lastRead != "" { | ||
| 476 | // The commits are newest first: those before the last read head are new. | 470 | // The commits are newest first: those before the last read head are new. | ||
| 477 | // A head that is not among them (the branch was rewritten) marks none. | 471 | // A head that is not among them (the branch was rewritten) marks none. | ||
| 478 | - | for i, c := range res.Msg.GetCommits() { | 472 | + | for i, c := range res.Commits { |
| 479 | - | if c.GetId() != lastRead { | 473 | + | if c.ID.String() != lastRead { |
| 480 | continue | 474 | continue | ||
| 481 | } | 475 | } | ||
| 482 | for j := 0; j < i; j++ { | 476 | for j := 0; j < i; j++ { | ||
| @@ -484,7 +478,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 484 | } | 478 | } | ||
| 485 | } | 479 | } | ||
| 486 | } | 480 | } | ||
| 487 | - | if res.Msg.GetCommitsTruncated() { | 481 | + | if res.CommitsTruncated { |
| 488 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | 482 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | ||
| 489 | } | 483 | } | ||
| 490 | 484 | ||||
| @@ -502,7 +496,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 502 | switch { | 496 | switch { | ||
| 503 | case lastRead == "": | 497 | case lastRead == "": | ||
| 504 | p.SinceNote = "nothing was marked read yet: this is the whole change." | 498 | p.SinceNote = "nothing was marked read yet: this is the whole change." | ||
| 505 | - | case res.Msg.GetLastReadLost(): | 499 | + | case res.LastReadLost: |
| 506 | p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again." | 500 | p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again." | ||
| 507 | case len(p.Files) == 0: | 501 | case len(p.Files) == 0: | ||
| 508 | p.SinceNote = "nothing changed since " + short(lastRead) + "." | 502 | p.SinceNote = "nothing changed since " + short(lastRead) + "." | ||
| @@ -521,12 +515,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 521 | if canWrite { | 515 | if canWrite { | ||
| 522 | on = parseOn(r.URL.Query().Get("on")) | 516 | on = parseOn(r.URL.Query().Get("on")) | ||
| 523 | } | 517 | } | ||
| 524 | - | s.placeComments(&p, d.name(), n, res.Msg.GetComments(), head, !since, canWrite, on) | 518 | + | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) |
| 525 | lines, invisible := diffLines(p.Files) | 519 | lines, invisible := diffLines(p.Files) | ||
| 526 | p.Invisible = invisible | 520 | p.Invisible = invisible | ||
| 527 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | 521 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | ||
| 528 | if signed && open { | 522 | if signed && open { | ||
| 529 | - | p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead) | 523 | + | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) |
| 530 | } | 524 | } | ||
| 531 | if canWrite { | 525 | if canWrite { | ||
| 532 | p.GeneralForm = &CommentForm{ | 526 | p.GeneralForm = &CommentForm{ | ||
| @@ -537,8 +531,8 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 537 | l := s.repoLayout(ctx, d, "merge-requests") | 531 | l := s.repoLayout(ctx, d, "merge-requests") | ||
| 538 | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | 532 | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | ||
| 539 | if head != "" { | 533 | if head != "" { | ||
| 540 | - | pinRef(&l, target{rev: head, name: m.GetSourceBranch()}) | 534 | + | pinRef(&l, target{rev: head, name: m.Source}) |
| 541 | - | if !d.hasBranch(m.GetSourceBranch()) { | 535 | + | if !d.hasBranch(m.Source) { |
| 542 | pinRef(&l, target{rev: head, name: short(head), detached: true}) | 536 | pinRef(&l, target{rev: head, name: short(head), detached: true}) | ||
| 543 | } | 537 | } | ||
| 544 | } | 538 | } | ||
| @@ -551,7 +545,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 551 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | 545 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | ||
| 552 | } | 546 | } | ||
| 553 | p.Live = true | 547 | p.Live = true | ||
| 554 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch())) | 548 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)) |
| 555 | l.HasRail = true | 549 | l.HasRail = true | ||
| 556 | return MergeRequest(l, p), http.StatusOK, nil | 550 | return MergeRequest(l, p), http.StatusOK, nil | ||
| 557 | } | 551 | } | ||
| @@ -559,7 +553,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 559 | // hasBranch tells whether the repository has the branch. | 553 | // hasBranch tells whether the repository has the branch. | ||
| 560 | func (d *repoData) hasBranch(name string) bool { | 554 | func (d *repoData) hasBranch(name string) bool { | ||
| 561 | for _, b := range d.branches { | 555 | for _, b := range d.branches { | ||
| 562 | - | if b.GetShortName() == name { | 556 | + | if b.ShortName == name { |
| 563 | return true | 557 | return true | ||
| 564 | } | 558 | } | ||
| 565 | } | 559 | } | ||
| @@ -572,19 +566,17 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) * | |||||
| 572 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | 566 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | ||
| 573 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | 567 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | ||
| 574 | CanMarkRead: head != "" && head != lastRead, | 568 | CanMarkRead: head != "" && head != lastRead, | ||
| 575 | - | CanDeleteBranch: source != d.repo.GetDefaultBranch(), | 569 | + | CanDeleteBranch: source != d.repo.DefaultBranch, |
| 576 | } | 570 | } | ||
| 577 | ff, sq := false, false | 571 | ff, sq := false, false | ||
| 578 | - | if st := d.repo.GetSettings(); st != nil { | 572 | + | for _, mode := range d.repo.Settings.AllowedMergeModes { |
| 579 | - | for _, mode := range st.GetAllowedMergeModes() { | 573 | + | switch mode { |
| 580 | - | switch mode { | 574 | + | case domain.MergeModeFastForward: |
| 581 | - | case pb.MergeMode_MERGE_MODE_FAST_FORWARD: | 575 | + | ff = true |
| 582 | - | ff = true | 576 | + | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) |
| 583 | - | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | 577 | + | case domain.MergeModeSquash: |
| 584 | - | case pb.MergeMode_MERGE_MODE_SQUASH: | 578 | + | sq = true |
| 585 | - | sq = true | 579 | + | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) |
| 586 | - | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | |||
| 587 | - | } | |||
| 588 | } | 580 | } | ||
| 589 | } | 581 | } | ||
| 590 | if head != "" { | 582 | if head != "" { | ||
| @@ -637,14 +629,16 @@ func parseOn(s string) *lineRef { | |||||
| 637 | // the target, so a page that shows another diff (since the last read) lists | 629 | // the target, so a page that shows another diff (since the last read) lists | ||
| 638 | // them all below, with their line. A reader who can write gets the line numbers | 630 | // them all below, with their line. A reader who can write gets the line numbers | ||
| 639 | // as links to the form of their line; on designates the line whose form shows. | 631 | // as links to the form of their line; on designates the line whose form shows. | ||
| 640 | - | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) { | 632 | + | // |
| 633 | + | //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only | |||
| 634 | + | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) { | |||
| 641 | now := s.cfg.Now() | 635 | now := s.cfg.Now() | ||
| 642 | self := mrHref(repo, n) | 636 | self := mrHref(repo, n) | ||
| 643 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | 637 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | ||
| 644 | 638 | ||||
| 645 | type lineKey struct { | 639 | type lineKey struct { | ||
| 646 | path string | 640 | path string | ||
| 647 | - | side pb.DiffSide | 641 | + | side domain.DiffSide |
| 648 | line int | 642 | line int | ||
| 649 | } | 643 | } | ||
| 650 | index := map[lineKey]*DiffLine{} | 644 | index := map[lineKey]*DiffLine{} | ||
| @@ -657,13 +651,13 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 657 | for li := range f.Hunks[hi].Lines { | 651 | for li := range f.Hunks[hi].Lines { | ||
| 658 | ln := &f.Hunks[hi].Lines[li] | 652 | ln := &f.Hunks[hi].Lines[li] | ||
| 659 | if ln.oldN > 0 && ln.Kind != "add" { | 653 | if ln.oldN > 0 && ln.Kind != "add" { | ||
| 660 | - | index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln | 654 | + | index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln |
| 661 | if linkable { | 655 | if linkable { | ||
| 662 | ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | 656 | ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | ||
| 663 | } | 657 | } | ||
| 664 | } | 658 | } | ||
| 665 | if ln.newN > 0 && ln.Kind != "del" { | 659 | if ln.newN > 0 && ln.Kind != "del" { | ||
| 666 | - | index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln | 660 | + | index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln |
| 667 | if linkable { | 661 | if linkable { | ||
| 668 | ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | 662 | ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | ||
| 669 | } | 663 | } | ||
| @@ -671,7 +665,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 671 | } | 665 | } | ||
| 672 | } | 666 | } | ||
| 673 | } | 667 | } | ||
| 674 | - | find := func(path string, side pb.DiffSide, line int) *DiffLine { | 668 | + | find := func(path string, side domain.DiffSide, line int) *DiffLine { |
| 675 | if !inline { | 669 | if !inline { | ||
| 676 | return nil | 670 | return nil | ||
| 677 | } | 671 | } | ||
| @@ -679,34 +673,35 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 679 | } | 673 | } | ||
| 680 | 674 | ||||
| 681 | for _, c := range comments { | 675 | for _, c := range comments { | ||
| 676 | + | cid := strconv.FormatInt(c.ID, 10) | |||
| 682 | v := CommentView{ | 677 | v := CommentView{ | ||
| 683 | - | ID: c.GetId(), Author: visibleText(c.GetAuthor()), | 678 | + | ID: cid, Author: visibleText(c.Author), |
| 684 | - | Parts: plainParts(c.GetText()), Resolved: c.GetResolved(), | 679 | + | Parts: plainParts(c.Text.String()), Resolved: c.Resolved, |
| 685 | - | Outdated: c.GetCommitId() != "" && c.GetCommitId() != head, | 680 | + | Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head, |
| 686 | } | 681 | } | ||
| 687 | - | v.When, v.WhenAt = whenOf(now, c.GetCreatedAt()) | 682 | + | v.When, v.WhenAt = whenOf(now, c.CreatedAt) |
| 688 | - | if t := c.GetCreatedAt(); t.IsValid() { | 683 | + | if !c.CreatedAt.IsZero() { |
| 689 | - | v.Abs = absolute(t.AsTime()) | 684 | + | v.Abs = absolute(c.CreatedAt) |
| 690 | } | 685 | } | ||
| 691 | p.NComments++ | 686 | p.NComments++ | ||
| 692 | - | if !c.GetResolved() { | 687 | + | if !c.Resolved { |
| 693 | p.NUnresolved++ | 688 | p.NUnresolved++ | ||
| 694 | } | 689 | } | ||
| 695 | - | general := c.GetPath() == "" | 690 | + | general := c.Path == "" |
| 696 | var ln *DiffLine | 691 | var ln *DiffLine | ||
| 697 | if !general { | 692 | if !general { | ||
| 698 | - | ln = find(c.GetPath(), c.GetSide(), int(c.GetLine())) | 693 | + | ln = find(c.Path, c.Side, c.Line) |
| 699 | if ln == nil { | 694 | if ln == nil { | ||
| 700 | side := "new" | 695 | side := "new" | ||
| 701 | - | if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD { | 696 | + | if c.Side == domain.SideOld { |
| 702 | side = "old" | 697 | side = "old" | ||
| 703 | } | 698 | } | ||
| 704 | - | v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")" | 699 | + | v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")" |
| 705 | } | 700 | } | ||
| 706 | } | 701 | } | ||
| 707 | if canWrite { | 702 | if canWrite { | ||
| 708 | - | v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"} | 703 | + | v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"} |
| 709 | - | if c.GetResolved() { | 704 | + | if c.Resolved { |
| 710 | v.Resolve.Label = "Unresolve" | 705 | v.Resolve.Label = "Unresolve" | ||
| 711 | } | 706 | } | ||
| 712 | } | 707 | } | ||
| @@ -721,9 +716,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 721 | } | 716 | } | ||
| 722 | 717 | ||||
| 723 | if on != nil { | 718 | if on != nil { | ||
| 724 | - | side := pb.DiffSide_DIFF_SIDE_NEW | 719 | + | side := domain.SideNew |
| 725 | if on.side == "old" { | 720 | if on.side == "old" { | ||
| 726 | - | side = pb.DiffSide_DIFF_SIDE_OLD | 721 | + | side = domain.SideOld |
| 727 | } | 722 | } | ||
| 728 | if ln := find(on.path, side, on.line); ln != nil { | 723 | if ln := find(on.path, side, on.line); ln != nil { | ||
| 729 | ln.Form = &CommentForm{ | 724 | ln.Form = &CommentForm{ | ||
M internal/adapters/web/mr.templ +4 -5
| @@ -9,18 +9,17 @@ import "strings" | |||||
| 9 | templ MRList(l Layout, p MRListPage) { | 9 | templ MRList(l Layout, p MRListPage) { | ||
| 10 | @Shell(l, mrListRail(p)) { | 10 | @Shell(l, mrListRail(p)) { | ||
| 11 | if p.Live { | 11 | if p.Live { | ||
| 12 | - | <div id="mr-notice"></div> | 12 | + | @LiveSlot("mr-notice") |
| 13 | } | 13 | } | ||
| 14 | <div class="filter"> | 14 | <div class="filter"> | ||
| 15 | <label for="mr-filter" class="sr">filter</label> | 15 | <label for="mr-filter" class="sr">filter</label> | ||
| 16 | <input | 16 | <input | ||
| 17 | id="mr-filter" | 17 | id="mr-filter" | ||
| 18 | type="search" | 18 | type="search" | ||
| 19 | - | data-filter | 19 | + | data-filter=".mrs tbody tr" |
| 20 | placeholder="Filter by title, branch, author or state…" | 20 | placeholder="Filter by title, branch, author or state…" | ||
| 21 | autocomplete="off" | 21 | autocomplete="off" | ||
| 22 | - | hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 22 | + | hx-on:input="ermite.filter(this)" |
| 23 | - | let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')" | |||
| 24 | /> | 23 | /> | ||
| 25 | </div> | 24 | </div> | ||
| 26 | if p.Newest == "" { | 25 | if p.Newest == "" { | ||
| @@ -140,7 +139,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 140 | } | 139 | } | ||
| 141 | </dl> | 140 | </dl> | ||
| 142 | </header> | 141 | </header> | ||
| 143 | - | <div id="mr-notice"></div> | 142 | + | @LiveSlot("mr-notice") |
| 144 | if len(p.Desc) > 0 { | 143 | if len(p.Desc) > 0 { | ||
| 145 | <p class="mr-desc">@userText(p.Desc)</p> | 144 | <p class="mr-desc">@userText(p.Desc)</p> | ||
| 146 | } | 145 | } | ||
M internal/adapters/web/mr_post.go +139 -71
| @@ -5,24 +5,23 @@ package web | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | - | "fmt" | |||
| 9 | "net/http" | 8 | "net/http" | ||
| 10 | "strconv" | 9 | "strconv" | ||
| 11 | "strings" | 10 | "strings" | ||
| 12 | 11 | ||||
| 13 | - | "connectrpc.com/connect" | |||
| 14 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 15 | 13 | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/domain" |
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | ) | 16 | ) | ||
| 18 | 17 | ||||
| 19 | // The writes of the merge request pages. Each is a POST that passed the gate | 18 | // The writes of the merge request pages. Each is a POST that passed the gate | ||
| 20 | - | // (an Origin of this site and the owner's session), calls the API as the | 19 | + | // (an Origin of this site and the owner's session), calls the use case as the |
| 21 | // owner, and answers 303 to the page that shows the result, with | 20 | // owner, and answers 303 to the page that shows the result, with | ||
| 22 | - | // "Cache-Control: no-store". A request the API refuses is answered with a | 21 | + | // "Cache-Control: no-store". A request the use case refuses is answered with a |
| 23 | - | // state page that says why, in the words of the API for the rules the owner | 22 | + | // state page that says why, in the words of the use case for the rules the |
| 24 | - | // can fix (a text rule, a moved branch, a full merge request), and without | 23 | + | // owner can fix (a text rule, a moved branch, a full merge request), and |
| 25 | - | // detail for anything else. | 24 | + | // without detail for anything else. |
| 26 | 25 | ||||
| 27 | // maxFormBytes bounds the body of a form: a description of 8,000 characters is | 26 | // maxFormBytes bounds the body of a form: a description of 8,000 characters is | ||
| 28 | // at most 32,000 bytes. | 27 | // at most 32,000 bytes. | ||
| @@ -44,8 +43,8 @@ func seeOther(w http.ResponseWriter, r *http.Request, to string) { | |||||
| 44 | http.Redirect(w, r, to, http.StatusSeeOther) | 43 | http.Redirect(w, r, to, http.StatusSeeOther) | ||
| 45 | } | 44 | } | ||
| 46 | 45 | ||||
| 47 | - | // mrAction wraps a POST handler: it bounds the API calls, reads the form and | 46 | + | // mrAction wraps a POST handler: it bounds the use case calls, reads the form |
| 48 | - | // maps the error of the API to a page. | 47 | + | // and maps the error of the use case to a page. |
| 49 | func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc { | 48 | func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc { | ||
| 50 | return func(w http.ResponseWriter, r *http.Request) { | 49 | return func(w http.ResponseWriter, r *http.Request) { | ||
| 51 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | 50 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | ||
| @@ -64,37 +63,42 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||||
| 64 | 63 | ||||
| 65 | // actionFailed answers a refused action. | 64 | // actionFailed answers a refused action. | ||
| 66 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | 65 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | ||
| 67 | - | msg := "" | 66 | + | // The words of the use case, for the rules the owner can fix. |
| 68 | - | var ce *connect.Error | 67 | + | msg := err.Error() |
| 69 | - | if errors.As(err, &ce) { | 68 | + | // The order is that of the API's mapping of the domain errors to codes |
| 70 | - | msg = ce.Message() | 69 | + | // (rpc.codeOf), so that a web action answers what the API would. |
| 71 | - | } | 70 | + | switch { |
| 72 | - | switch code := connect.CodeOf(err); { | 71 | + | case errors.Is(err, context.Canceled): |
| 73 | - | case code == connect.CodeNotFound: | 72 | + | // The browser is gone. |
| 73 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 74 | w.Header().Set("Cache-Control", "no-store") | 74 | w.Header().Set("Cache-Control", "no-store") | ||
| 75 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | 75 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | ||
| 76 | - | case code == connect.CodeInvalidArgument: | 76 | + | case errors.Is(err, domain.ErrAlreadyExists): |
| 77 | - | s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back) | |||
| 78 | - | case code == connect.CodeFailedPrecondition, code == connect.CodeAborted: | |||
| 79 | - | s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back) | |||
| 80 | - | case code == connect.CodeAlreadyExists: | |||
| 81 | s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back) | 77 | s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back) | ||
| 82 | - | case code == connect.CodeResourceExhausted: | 78 | + | case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected): |
| 83 | - | s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back) | |||
| 84 | - | case code == connect.CodePermissionDenied, code == connect.CodeUnauthenticated: | |||
| 85 | s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back) | 79 | s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back) | ||
| 86 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | 80 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): |
| 87 | - | // The browser is gone. | 81 | + | s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back) |
| 82 | + | case errors.Is(err, domain.ErrResourceExhausted): | |||
| 83 | + | s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back) | |||
| 84 | + | case domain.IsInvalid(err): | |||
| 85 | + | s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back) | |||
| 88 | default: | 86 | default: | ||
| 89 | - | id := requestID() | 87 | + | s.internalFailure(ctx, w, r, err) |
| 90 | - | log := zerowrap.FromCtx(ctx) | |||
| 91 | - | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed") | |||
| 92 | - | w.Header().Set("Cache-Control", "no-store") | |||
| 93 | - | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |||
| 94 | } | 88 | } | ||
| 95 | } | 89 | } | ||
| 96 | 90 | ||||
| 97 | - | // rule is a message of the API fit to show: the invisible characters marked, | 91 | + | // internalFailure answers a failure of an action that is not the owner's to |
| 92 | + | // fix: the 500 page with an id, and the cause in the log. | |||
| 93 | + | func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |||
| 94 | + | id := requestID() | |||
| 95 | + | log := zerowrap.FromCtx(ctx) | |||
| 96 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed") | |||
| 97 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 98 | + | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // rule is a message of the use case fit to show: the invisible characters marked, | |||
| 98 | // bounded, and a fallback for an empty one. | 102 | // bounded, and a fallback for an empty one. | ||
| 99 | func rule(msg, fallback string) string { | 103 | func rule(msg, fallback string) string { | ||
| 100 | msg = strings.TrimSpace(visibleText(msg)) | 104 | msg = strings.TrimSpace(visibleText(msg)) | ||
| @@ -126,69 +130,116 @@ func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, | |||||
| 126 | repo := r.PathValue("repo") | 130 | repo := r.PathValue("repo") | ||
| 127 | source := r.PostFormValue("source") | 131 | source := r.PostFormValue("source") | ||
| 128 | back := repoHref(repo, "compare", source) | 132 | back := repoHref(repo, "compare", source) | ||
| 129 | - | res, err := s.cfg.MergeRequests.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | 133 | + | name, err := domain.ParseRepoName(repo) |
| 130 | - | Repo: repo, Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")), | 134 | + | if err != nil { |
| 131 | - | SourceBranch: source, TargetBranch: r.PostFormValue("target"), | 135 | + | return "", back, err |
| 132 | - | })) | 136 | + | } |
| 137 | + | mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{ | |||
| 138 | + | Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")), | |||
| 139 | + | Source: source, Target: r.PostFormValue("target"), | |||
| 140 | + | }) | |||
| 133 | if err != nil { | 141 | if err != nil { | ||
| 134 | return "", back, err | 142 | return "", back, err | ||
| 135 | } | 143 | } | ||
| 136 | - | return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil | 144 | + | return mrHref(repo, mr.Number), back, nil |
| 137 | } | 145 | } | ||
| 138 | 146 | ||||
| 147 | + | // formError is a form the server refuses before asking a use case: it matches | |||
| 148 | + | // domain.ErrInvalid and says just what is wrong. | |||
| 149 | + | type formError string | |||
| 150 | + | ||||
| 151 | + | func (e formError) Error() string { return string(e) } | |||
| 152 | + | ||||
| 153 | + | func (formError) Unwrap() error { return domain.ErrInvalid } | |||
| 154 | + | ||||
| 139 | // mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or | 155 | // mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or | ||
| 140 | // one on a line (path, line and side). | 156 | // one on a line (path, line and side). | ||
| 141 | func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) { | 157 | func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) { | ||
| 142 | repo := r.PathValue("repo") | 158 | repo := r.PathValue("repo") | ||
| 143 | n, err := number(r) | 159 | n, err := number(r) | ||
| 144 | if err != nil { | 160 | if err != nil { | ||
| 145 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 161 | + | return "", "", err |
| 146 | } | 162 | } | ||
| 147 | back := mrHref(repo, n) | 163 | back := mrHref(repo, n) | ||
| 148 | - | req := &pb.AddCommentRequest{Repo: repo, Number: n, Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")} | 164 | + | params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")} |
| 149 | - | if req.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" { | 165 | + | if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" { |
| 150 | line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32) | 166 | line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32) | ||
| 151 | if err != nil { | 167 | if err != nil { | ||
| 152 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("line is not a number")) | 168 | + | return "", back, formError("line is not a number") |
| 153 | } | 169 | } | ||
| 154 | - | req.Line = int32(line) | 170 | + | params.Line = int(line) |
| 155 | switch r.PostFormValue("side") { | 171 | switch r.PostFormValue("side") { | ||
| 156 | case "new": | 172 | case "new": | ||
| 157 | - | req.Side = pb.DiffSide_DIFF_SIDE_NEW | 173 | + | params.Side = domain.SideNew |
| 158 | case "old": | 174 | case "old": | ||
| 159 | - | req.Side = pb.DiffSide_DIFF_SIDE_OLD | 175 | + | params.Side = domain.SideOld |
| 160 | default: | 176 | default: | ||
| 161 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("side must be new or old")) | 177 | + | return "", back, formError("side must be new or old") |
| 162 | } | 178 | } | ||
| 163 | } | 179 | } | ||
| 164 | - | res, err := s.cfg.MergeRequests.AddComment(ctx, connect.NewRequest(req)) | 180 | + | name, err := domain.ParseRepoName(repo) |
| 165 | if err != nil { | 181 | if err != nil { | ||
| 166 | return "", back, err | 182 | return "", back, err | ||
| 167 | } | 183 | } | ||
| 168 | - | return back + "#c" + res.Msg.GetComment().GetId(), back, nil | 184 | + | c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params) |
| 185 | + | if err != nil { | |||
| 186 | + | return "", back, err | |||
| 187 | + | } | |||
| 188 | + | return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil | |||
| 169 | } | 189 | } | ||
| 170 | 190 | ||||
| 171 | // mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve. | 191 | // mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve. | ||
| 172 | // | 192 | // | ||
| 173 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into an API call; the state of the comment is decided by the API | 193 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into a use case call; the state of the comment is decided by the use case |
| 174 | func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) { | 194 | func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) { | ||
| 175 | repo := r.PathValue("repo") | 195 | repo := r.PathValue("repo") | ||
| 176 | n, err := number(r) | 196 | n, err := number(r) | ||
| 177 | if err != nil { | 197 | if err != nil { | ||
| 178 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 198 | + | return "", "", err |
| 179 | } | 199 | } | ||
| 180 | back := mrHref(repo, n) | 200 | back := mrHref(repo, n) | ||
| 181 | resolved, err := formBool(r.PostFormValue("resolved")) | 201 | resolved, err := formBool(r.PostFormValue("resolved")) | ||
| 182 | if err != nil { | 202 | if err != nil { | ||
| 183 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("resolved: %w", err)) | 203 | + | return "", back, formError("resolved: " + err.Error()) |
| 184 | } | 204 | } | ||
| 185 | - | res, err := s.cfg.MergeRequests.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{ | 205 | + | name, err := domain.ParseRepoName(repo) |
| 186 | - | Repo: repo, Number: n, CommentId: r.PathValue("id"), Resolved: resolved, | |||
| 187 | - | })) | |||
| 188 | if err != nil { | 206 | if err != nil { | ||
| 189 | return "", back, err | 207 | return "", back, err | ||
| 190 | } | 208 | } | ||
| 191 | - | return back + "#c" + res.Msg.GetComment().GetId(), back, nil | 209 | + | // An id this server never issued is 0: the use case answers not found, |
| 210 | + | // after the repository and the scope were checked, as for any unknown | |||
| 211 | + | // comment. | |||
| 212 | + | c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved) | |||
| 213 | + | if err != nil { | |||
| 214 | + | return "", back, err | |||
| 215 | + | } | |||
| 216 | + | return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // commentID parses the id of a comment in a path: a positive decimal number, | |||
| 220 | + | // written without sign or leading zeros. Anything else is 0, which names no | |||
| 221 | + | // comment. | |||
| 222 | + | func commentID(s string) int64 { | |||
| 223 | + | n, err := strconv.ParseInt(s, 10, 64) | |||
| 224 | + | if err != nil || n < 1 || strconv.FormatInt(n, 10) != s { | |||
| 225 | + | return 0 | |||
| 226 | + | } | |||
| 227 | + | return n | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // headID parses a commit id of a form; the empty string is the zero id. | |||
| 231 | + | // | |||
| 232 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here | |||
| 233 | + | func headID(s string) (domain.ObjectID, error) { | |||
| 234 | + | if s == "" { | |||
| 235 | + | return domain.ObjectID{}, nil | |||
| 236 | + | } | |||
| 237 | + | var v domain.Violations | |||
| 238 | + | id, err := domain.ParseObjectID(s) | |||
| 239 | + | if err != nil { | |||
| 240 | + | v.Add("head_id", "is not a full commit id") | |||
| 241 | + | } | |||
| 242 | + | return id, v.Err() | |||
| 192 | } | 243 | } | ||
| 193 | 244 | ||||
| 194 | // mrClose is POST /<repo>/merge-requests/<n>/close. | 245 | // mrClose is POST /<repo>/merge-requests/<n>/close. | ||
| @@ -196,10 +247,14 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, | |||||
| 196 | repo := r.PathValue("repo") | 247 | repo := r.PathValue("repo") | ||
| 197 | n, err := number(r) | 248 | n, err := number(r) | ||
| 198 | if err != nil { | 249 | if err != nil { | ||
| 199 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 250 | + | return "", "", err |
| 200 | } | 251 | } | ||
| 201 | back := mrHref(repo, n) | 252 | back := mrHref(repo, n) | ||
| 202 | - | if _, err := s.cfg.MergeRequests.CloseMergeRequest(ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: repo, Number: n})); err != nil { | 253 | + | name, err := domain.ParseRepoName(repo) |
| 254 | + | if err != nil { | |||
| 255 | + | return "", back, err | |||
| 256 | + | } | |||
| 257 | + | if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil { | |||
| 203 | return "", back, err | 258 | return "", back, err | ||
| 204 | } | 259 | } | ||
| 205 | return back, back, nil | 260 | return back, back, nil | ||
| @@ -213,22 +268,29 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||||
| 213 | repo := r.PathValue("repo") | 268 | repo := r.PathValue("repo") | ||
| 214 | n, err := number(r) | 269 | n, err := number(r) | ||
| 215 | if err != nil { | 270 | if err != nil { | ||
| 216 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 271 | + | return "", "", err |
| 217 | } | 272 | } | ||
| 218 | back := mrHref(repo, n) | 273 | back := mrHref(repo, n) | ||
| 219 | - | var mode pb.MergeMode | 274 | + | var mode domain.MergeMode |
| 220 | switch r.PostFormValue("mode") { | 275 | switch r.PostFormValue("mode") { | ||
| 221 | case "fast-forward": | 276 | case "fast-forward": | ||
| 222 | - | mode = pb.MergeMode_MERGE_MODE_FAST_FORWARD | 277 | + | mode = domain.MergeModeFastForward |
| 223 | case "squash": | 278 | case "squash": | ||
| 224 | - | mode = pb.MergeMode_MERGE_MODE_SQUASH | 279 | + | mode = domain.MergeModeSquash |
| 225 | default: | 280 | default: | ||
| 226 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("mode must be fast-forward or squash")) | 281 | + | return "", back, formError("mode must be fast-forward or squash") |
| 227 | } | 282 | } | ||
| 228 | - | if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{ | 283 | + | name, err := domain.ParseRepoName(repo) |
| 229 | - | Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"), | 284 | + | if err != nil { |
| 230 | - | DeleteSourceBranch: r.PostFormValue("delete_branch") == "1", | 285 | + | return "", back, err |
| 231 | - | })); err != nil { | 286 | + | } |
| 287 | + | head, err := headID(r.PostFormValue("head_id")) | |||
| 288 | + | if err != nil { | |||
| 289 | + | return "", back, err | |||
| 290 | + | } | |||
| 291 | + | if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |||
| 292 | + | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | |||
| 293 | + | }); err != nil { | |||
| 232 | return "", back, err | 294 | return "", back, err | ||
| 233 | } | 295 | } | ||
| 234 | return back, back, nil | 296 | return back, back, nil | ||
| @@ -240,12 +302,18 @@ func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, e | |||||
| 240 | repo := r.PathValue("repo") | 302 | repo := r.PathValue("repo") | ||
| 241 | n, err := number(r) | 303 | n, err := number(r) | ||
| 242 | if err != nil { | 304 | if err != nil { | ||
| 243 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 305 | + | return "", "", err |
| 244 | } | 306 | } | ||
| 245 | back := mrHref(repo, n) | 307 | back := mrHref(repo, n) | ||
| 246 | - | if _, err := s.cfg.MergeRequests.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{ | 308 | + | name, err := domain.ParseRepoName(repo) |
| 247 | - | Repo: repo, Number: n, HeadId: r.PostFormValue("head_id"), | 309 | + | if err != nil { |
| 248 | - | })); err != nil { | 310 | + | return "", back, err |
| 311 | + | } | |||
| 312 | + | head, err := headID(r.PostFormValue("head_id")) | |||
| 313 | + | if err != nil { | |||
| 314 | + | return "", back, err | |||
| 315 | + | } | |||
| 316 | + | if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil { | |||
| 249 | return "", back, err | 317 | return "", back, err | ||
| 250 | } | 318 | } | ||
| 251 | return back, back, nil | 319 | return back, back, nil | ||
| @@ -263,7 +331,7 @@ func formBool(s string) (bool, error) { | |||||
| 263 | } | 331 | } | ||
| 264 | 332 | ||||
| 265 | // normalizeNewlines turns the CRLF of a browser textarea into LF, the line | 333 | // normalizeNewlines turns the CRLF of a browser textarea into LF, the line | ||
| 266 | - | // break the API stores. | 334 | + | // break the use case stores. |
| 267 | func normalizeNewlines(s string) string { | 335 | func normalizeNewlines(s string) string { | ||
| 268 | return strings.ReplaceAll(s, "\r\n", "\n") | 336 | return strings.ReplaceAll(s, "\r\n", "\n") | ||
| 269 | } | 337 | } | ||
M internal/adapters/web/mr_templ.go +202 -194 generated file: collapsed
M internal/adapters/web/overlays.templ +4 -10
| @@ -37,8 +37,8 @@ templ OverlayRefs(l Layout) { | |||||
| 37 | type="search" | 37 | type="search" | ||
| 38 | placeholder="Switch branch or tag…" | 38 | placeholder="Switch branch or tag…" | ||
| 39 | autocomplete="off" | 39 | autocomplete="off" | ||
| 40 | - | data-pick | 40 | + | data-pick="#panel-refs li" |
| 41 | - | hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))" | 41 | + | hx-on:input="ermite.filter(this)" |
| 42 | /> | 42 | /> | ||
| 43 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | 43 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | ||
| 44 | </div> | 44 | </div> | ||
| @@ -96,14 +96,8 @@ templ OverlayCmd(items []CmdItem) { | |||||
| 96 | autocomplete="off" | 96 | autocomplete="off" | ||
| 97 | spellcheck="false" | 97 | spellcheck="false" | ||
| 98 | aria-controls="cmd-list" | 98 | aria-controls="cmd-list" | ||
| 99 | - | hx-on:input="let t = this.value.toLowerCase().trim(); | 99 | + | hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" |
| 100 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | 100 | + | hx-on:keydown="ermite.cmdKey(event)" |
| 101 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" | |||
| 102 | - | hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 103 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 104 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 105 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 106 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }" | |||
| 107 | /> | 101 | /> | ||
| 108 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | 102 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | ||
| 109 | </div> | 103 | </div> | ||
M internal/adapters/web/overlays_templ.go +6 -6 generated file: collapsed
M internal/adapters/web/pages.templ +4 -5
| @@ -10,10 +10,10 @@ templ Home(l Layout, p HomePage) { | |||||
| 10 | <input | 10 | <input | ||
| 11 | id="home-filter" | 11 | id="home-filter" | ||
| 12 | type="search" | 12 | type="search" | ||
| 13 | - | data-filter | 13 | + | data-filter="#repo-rows > tr" |
| 14 | placeholder="Filter repositories…" | 14 | placeholder="Filter repositories…" | ||
| 15 | autocomplete="off" | 15 | autocomplete="off" | ||
| 16 | - | hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')" | 16 | + | hx-on:input="ermite.filter(this)" |
| 17 | /> | 17 | /> | ||
| 18 | </div> | 18 | </div> | ||
| 19 | <table class="list home" aria-label="Repositories"> | 19 | <table class="list home" aria-label="Repositories"> | ||
| @@ -94,11 +94,10 @@ templ Refs(l Layout, p RefsPage) { | |||||
| 94 | <input | 94 | <input | ||
| 95 | id="ref-filter" | 95 | id="ref-filter" | ||
| 96 | type="search" | 96 | type="search" | ||
| 97 | - | data-filter | 97 | + | data-filter="#ref-rows > tr" |
| 98 | placeholder="Filter branches and tags…" | 98 | placeholder="Filter branches and tags…" | ||
| 99 | autocomplete="off" | 99 | autocomplete="off" | ||
| 100 | - | hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 100 | + | hx-on:input="ermite.filter(this)" |
| 101 | - | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')" | |||
| 102 | /> | 101 | /> | ||
| 103 | </div> | 102 | </div> | ||
| 104 | <table class="list refs" aria-label="Branches and tags"> | 103 | <table class="list refs" aria-label="Branches and tags"> | ||
M internal/adapters/web/pages_templ.go +22 -22 generated file: collapsed
M internal/adapters/web/pathcache_test.go +9 -0
| @@ -31,6 +31,7 @@ func (c *pathCache) has(id string) bool { | |||||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | 33 | func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | ||
| 34 | + | t.Parallel() | |||
| 34 | c := newPathCache(100, 1) | 35 | c := newPathCache(100, 1) | ||
| 35 | blob := func(n int) []byte { return make([]byte, n) } | 36 | blob := func(n int) []byte { return make([]byte, n) } | ||
| 36 | c.store("a", blob(25)) | 37 | c.store("a", blob(25)) | ||
| @@ -57,6 +58,7 @@ func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | |||||
| 57 | // A list over a quarter of the budget is not kept, since it would push out the | 58 | // A list over a quarter of the budget is not kept, since it would push out the | ||
| 58 | // others; one at the limit is. | 59 | // others; one at the limit is. | ||
| 59 | func TestPathCacheSkipsOversizedEntries(t *testing.T) { | 60 | func TestPathCacheSkipsOversizedEntries(t *testing.T) { | ||
| 61 | + | t.Parallel() | |||
| 60 | c := newPathCache(100, 1) | 62 | c := newPathCache(100, 1) | ||
| 61 | var builds atomic.Int32 | 63 | var builds atomic.Int32 | ||
| 62 | big := func(n int) func() ([]byte, error) { | 64 | big := func(n int) func() ([]byte, error) { | ||
| @@ -80,6 +82,7 @@ func TestPathCacheSkipsOversizedEntries(t *testing.T) { | |||||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 82 | func TestPathCacheConcurrent(t *testing.T) { | 84 | func TestPathCacheConcurrent(t *testing.T) { | ||
| 85 | + | t.Parallel() | |||
| 83 | c := newPathCache(1000, 1) | 86 | c := newPathCache(1000, 1) | ||
| 84 | var wg sync.WaitGroup | 87 | var wg sync.WaitGroup | ||
| 85 | for g := range 8 { | 88 | for g := range 8 { | ||
| @@ -122,6 +125,7 @@ func building(c *pathCache, n int) bool { | |||||
| 122 | // Concurrent misses for one commit build once; a failure is shared and not | 125 | // Concurrent misses for one commit build once; a failure is shared and not | ||
| 123 | // stored; a waiter that gives up leaves the build running. | 126 | // stored; a waiter that gives up leaves the build running. | ||
| 124 | func TestPathCacheLoadBuildsOnce(t *testing.T) { | 127 | func TestPathCacheLoadBuildsOnce(t *testing.T) { | ||
| 128 | + | t.Parallel() | |||
| 125 | c := newPathCache(1000, 4) | 129 | c := newPathCache(1000, 4) | ||
| 126 | var builds atomic.Int32 | 130 | var builds atomic.Int32 | ||
| 127 | release := make(chan struct{}) | 131 | release := make(chan struct{}) | ||
| @@ -172,6 +176,7 @@ func TestPathCacheLoadBuildsOnce(t *testing.T) { | |||||
| 172 | // commit waits for the running build, and leaves with its ctx if it is | 176 | // commit waits for the running build, and leaves with its ctx if it is | ||
| 173 | // cancelled first. The slot is the build's, not the caller's. | 177 | // cancelled first. The slot is the build's, not the caller's. | ||
| 174 | func TestPathCacheLoadBoundsBuilds(t *testing.T) { | 178 | func TestPathCacheLoadBoundsBuilds(t *testing.T) { | ||
| 179 | + | t.Parallel() | |||
| 175 | c := newPathCache(1000, 1) | 180 | c := newPathCache(1000, 1) | ||
| 176 | started, release := make(chan struct{}), make(chan struct{}) | 181 | started, release := make(chan struct{}), make(chan struct{}) | ||
| 177 | first := make(chan error, 1) | 182 | first := make(chan error, 1) | ||
| @@ -218,6 +223,7 @@ func TestPathCacheLoadBoundsBuilds(t *testing.T) { | |||||
| 218 | // The slot of a build is kept after its callers leave, and given back when it | 223 | // The slot of a build is kept after its callers leave, and given back when it | ||
| 219 | // ends, whether it fails or panics. | 224 | // ends, whether it fails or panics. | ||
| 220 | func TestPathCacheSlotOutlivesCallers(t *testing.T) { | 225 | func TestPathCacheSlotOutlivesCallers(t *testing.T) { | ||
| 226 | + | t.Parallel() | |||
| 221 | c := newPathCache(1000, 1) | 227 | c := newPathCache(1000, 1) | ||
| 222 | started, release := make(chan struct{}), make(chan struct{}) | 228 | started, release := make(chan struct{}), make(chan struct{}) | ||
| 223 | ctx, cancel := context.WithCancel(context.Background()) | 229 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -240,6 +246,7 @@ func TestPathCacheSlotOutlivesCallers(t *testing.T) { | |||||
| 240 | // A build that panics is handed to its waiters as an error, leaves nothing | 246 | // A build that panics is handed to its waiters as an error, leaves nothing | ||
| 241 | // in flight or cached, and does not take the process down. | 247 | // in flight or cached, and does not take the process down. | ||
| 242 | func TestPathCacheLoadPanic(t *testing.T) { | 248 | func TestPathCacheLoadPanic(t *testing.T) { | ||
| 249 | + | t.Parallel() | |||
| 243 | c := newPathCache(1000, 4) | 250 | c := newPathCache(1000, 4) | ||
| 244 | started, release := make(chan struct{}), make(chan struct{}) | 251 | started, release := make(chan struct{}), make(chan struct{}) | ||
| 245 | var once sync.Once | 252 | var once sync.Once | ||
| @@ -272,6 +279,7 @@ func TestPathCacheLoadPanic(t *testing.T) { | |||||
| 272 | // The caller that starts a build is released with its context too, and the | 279 | // The caller that starts a build is released with its context too, and the | ||
| 273 | // build goes on for the others. | 280 | // build goes on for the others. | ||
| 274 | func TestPathCacheLoadStarterLeaves(t *testing.T) { | 281 | func TestPathCacheLoadStarterLeaves(t *testing.T) { | ||
| 282 | + | t.Parallel() | |||
| 275 | c := newPathCache(1000, 4) | 283 | c := newPathCache(1000, 4) | ||
| 276 | release := make(chan struct{}) | 284 | release := make(chan struct{}) | ||
| 277 | build := func() ([]byte, error) { <-release; return []byte("list"), nil } | 285 | build := func() ([]byte, error) { <-release; return []byte("list"), nil } | ||
| @@ -294,6 +302,7 @@ func TestPathCacheLoadStarterLeaves(t *testing.T) { | |||||
| 294 | // A failure belongs to the repository that built it: another repository | 302 | // A failure belongs to the repository that built it: another repository | ||
| 295 | // asking for the same commit builds its own. | 303 | // asking for the same commit builds its own. | ||
| 296 | func TestPathCacheLoadFailureStaysInRepo(t *testing.T) { | 304 | func TestPathCacheLoadFailureStaysInRepo(t *testing.T) { | ||
| 305 | + | t.Parallel() | |||
| 297 | c := newPathCache(1000, 4) | 306 | c := newPathCache(1000, 4) | ||
| 298 | started, release := make(chan struct{}), make(chan struct{}) | 307 | started, release := make(chan struct{}), make(chan struct{}) | ||
| 299 | boom := errors.New("boom") | 308 | boom := errors.New("boom") | ||
M internal/adapters/web/paths.go +19 -44
| @@ -11,11 +11,8 @@ import ( | |||||
| 11 | "io" | 11 | "io" | ||
| 12 | "net/http" | 12 | "net/http" | ||
| 13 | "strconv" | 13 | "strconv" | ||
| 14 | - | "strings" | |||
| 15 | 14 | ||||
| 16 | - | "connectrpc.com/connect" | 15 | + | "bnema.dev/ermite/internal/domain" |
| 17 | - | ||||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 19 | ) | 16 | ) | ||
| 20 | 17 | ||||
| 21 | // errBuildPanicked is what the waiters of a path list get when its build | 18 | // errBuildPanicked is what the waiters of a path list get when its build | ||
| @@ -31,34 +28,41 @@ type pathList struct { | |||||
| 31 | // paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for | 28 | // paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for | ||
| 32 | // the file finder (files.js). The commit must be a full id; the answer never | 29 | // the file finder (files.js). The commit must be a full id; the answer never | ||
| 33 | // changes for it, but who may read it does, so every request is authorized | 30 | // changes for it, but who may read it does, so every request is authorized | ||
| 34 | - | // first (ResolveRevision runs the access rule) and only then served from the | 31 | + | // first (ResolveRevision runs the access rule, as the caller of the request) and only then served from the |
| 35 | // cache of lists. Caching follows raw: a visitor revalidates every time, with | 32 | // cache of lists. Caching follows raw: a visitor revalidates every time, with | ||
| 36 | // an ETag that makes it a 304 (answered before the list is loaded, so it never | 33 | // an ETag that makes it a 304 (answered before the list is loaded, so it never | ||
| 37 | // waits for a build); the owner's copy is private and never stored. The ETag | 34 | // waits for a build); the owner's copy is private and never stored. The ETag | ||
| 38 | // differs for the gzip and the plain variant. | 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 | |||
| 39 | func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | 38 | func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | ||
| 40 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | 39 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | ||
| 41 | defer cancel() | 40 | defer cancel() | ||
| 42 | - | repo, sha := r.PathValue("repo"), r.PathValue("sha") | 41 | + | sha := r.PathValue("sha") |
| 43 | if !isFullID(sha) { | 42 | if !isFullID(sha) { | ||
| 44 | s.fail(ctx, w, r, errNotFound) | 43 | s.fail(ctx, w, r, errNotFound) | ||
| 45 | return | 44 | return | ||
| 46 | } | 45 | } | ||
| 47 | - | res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: sha})) | 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) | |||
| 48 | if err != nil { | 52 | if err != nil { | ||
| 49 | s.fail(ctx, w, r, err) | 53 | s.fail(ctx, w, r, err) | ||
| 50 | return | 54 | return | ||
| 51 | } | 55 | } | ||
| 52 | // A ref can be named like a commit id: only the commit itself is served, | 56 | // A ref can be named like a commit id: only the commit itself is served, | ||
| 53 | // since the cache is keyed by it. | 57 | // since the cache is keyed by it. | ||
| 54 | - | if res.Msg.GetCommitId() != sha { | 58 | + | if res.CommitID.String() != sha { |
| 55 | s.fail(ctx, w, r, errNotFound) | 59 | s.fail(ctx, w, r, errNotFound) | ||
| 56 | return | 60 | return | ||
| 57 | } | 61 | } | ||
| 58 | 62 | ||||
| 59 | // The validator names the variant that would be served: the gzip bytes | 63 | // The validator names the variant that would be served: the gzip bytes | ||
| 60 | // and the plain JSON are different representations. | 64 | // and the plain JSON are different representations. | ||
| 61 | - | gz := acceptsGzip(r.Header.Get("Accept-Encoding")) | 65 | + | gz := acceptsGzip(r.Header) |
| 62 | etag := `"paths-` + sha + `"` | 66 | etag := `"paths-` + sha + `"` | ||
| 63 | if gz { | 67 | if gz { | ||
| 64 | etag = `"paths-` + sha + `-gz"` | 68 | etag = `"paths-` + sha + `-gz"` | ||
| @@ -77,7 +81,7 @@ func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | |||||
| 77 | w.WriteHeader(http.StatusNotModified) | 81 | w.WriteHeader(http.StatusNotModified) | ||
| 78 | return | 82 | return | ||
| 79 | } | 83 | } | ||
| 80 | - | body, err := s.pathCache.load(ctx, repo, sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) }) | 84 | + | body, err := s.pathCache.load(ctx, repo.String(), sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) }) |
| 81 | if err != nil { | 85 | if err != nil { | ||
| 82 | s.fail(ctx, w, r, err) | 86 | s.fail(ctx, w, r, err) | ||
| 83 | return | 87 | return | ||
| @@ -122,17 +126,17 @@ func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) | |||||
| 122 | // buildPaths lists the paths of a commit and returns them as gzipped JSON. | 126 | // buildPaths lists the paths of a commit and returns them as gzipped JSON. | ||
| 123 | // The build is shared by the requests that wait for it, so it does not stop | 127 | // The build is shared by the requests that wait for it, so it does not stop | ||
| 124 | // when the request that started it ends. | 128 | // when the request that started it ends. | ||
| 125 | - | func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, error) { | 129 | + | func (s *Server) buildPaths(ctx context.Context, repo domain.RepoName, sha string) ([]byte, error) { |
| 126 | ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout) | 130 | ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout) | ||
| 127 | defer cancel() | 131 | defer cancel() | ||
| 128 | - | res, err := s.cfg.Git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: sha})) | 132 | + | list, err := s.listPaths(ctx, repo, sha) |
| 129 | if err != nil { | 133 | if err != nil { | ||
| 130 | return nil, err | 134 | return nil, err | ||
| 131 | } | 135 | } | ||
| 132 | - | if res.Msg.GetCommitId() != sha { | 136 | + | if list.CommitID.String() != sha { |
| 133 | return nil, errNotFound | 137 | return nil, errNotFound | ||
| 134 | } | 138 | } | ||
| 135 | - | paths := res.Msg.GetPaths() | 139 | + | paths := list.Paths |
| 136 | if paths == nil { | 140 | if paths == nil { | ||
| 137 | paths = []string{} | 141 | paths = []string{} | ||
| 138 | } | 142 | } | ||
| @@ -143,7 +147,7 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro | |||||
| 143 | } | 147 | } | ||
| 144 | enc := json.NewEncoder(zw) | 148 | enc := json.NewEncoder(zw) | ||
| 145 | enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are | 149 | enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are | ||
| 146 | - | if err := enc.Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil { | 150 | + | if err := enc.Encode(pathList{Paths: paths, Truncated: list.Truncated}); err != nil { |
| 147 | return nil, err | 151 | return nil, err | ||
| 148 | } | 152 | } | ||
| 149 | if err := zw.Close(); err != nil { | 153 | if err := zw.Close(); err != nil { | ||
| @@ -156,32 +160,3 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro | |||||
| 156 | copy(body, buf.Bytes()) | 160 | copy(body, buf.Bytes()) | ||
| 157 | return body, nil | 161 | return body, nil | ||
| 158 | } | 162 | } | ||
| 159 | - | ||||
| 160 | - | // acceptsGzip reports whether an Accept-Encoding header allows gzip. | |||
| 161 | - | func acceptsGzip(h string) bool { | |||
| 162 | - | for f := range strings.SplitSeq(h, ",") { | |||
| 163 | - | name, params, _ := strings.Cut(strings.TrimSpace(f), ";") | |||
| 164 | - | if !strings.EqualFold(strings.TrimSpace(name), "gzip") { | |||
| 165 | - | continue | |||
| 166 | - | } | |||
| 167 | - | if q, ok := strings.CutPrefix(strings.ReplaceAll(strings.TrimSpace(params), " ", ""), "q="); ok { | |||
| 168 | - | if v, err := strconv.ParseFloat(q, 64); err == nil && v <= 0 { | |||
| 169 | - | return false | |||
| 170 | - | } | |||
| 171 | - | } | |||
| 172 | - | return true | |||
| 173 | - | } | |||
| 174 | - | return false | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | // etagMatches reports whether an If-None-Match header lists etag (weak | |||
| 178 | - | // comparison, or "*"). | |||
| 179 | - | func etagMatches(header, etag string) bool { | |||
| 180 | - | for f := range strings.SplitSeq(header, ",") { | |||
| 181 | - | f = strings.TrimSpace(f) | |||
| 182 | - | if f == "*" || strings.TrimPrefix(f, "W/") == etag { | |||
| 183 | - | return true | |||
| 184 | - | } | |||
| 185 | - | } | |||
| 186 | - | return false | |||
| 187 | - | } | |||
M internal/adapters/web/paths_test.go +159 -116
| @@ -6,168 +6,211 @@ import ( | |||||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "compress/gzip" | 7 | "compress/gzip" | ||
| 8 | "context" | 8 | "context" | ||
| 9 | + | "errors" | |||
| 9 | "io" | 10 | "io" | ||
| 10 | "net/http" | 11 | "net/http" | ||
| 11 | "net/http/httptest" | 12 | "net/http/httptest" | ||
| 12 | "strings" | 13 | "strings" | ||
| 13 | - | "sync/atomic" | |||
| 14 | "testing" | 14 | "testing" | ||
| 15 | 15 | ||||
| 16 | - | "connectrpc.com/connect" | |||
| 17 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 18 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 19 | 19 | ||||
| 20 | - | pb "bnema.dev/ermite-api/gen/ermite" | 20 | + | "bnema.dev/ermite/internal/domain" |
| 21 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 21 | + | "bnema.dev/ermite/internal/ports" |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 22 | ) | 23 | ) | ||
| 23 | 24 | ||||
| 24 | - | // pathsGit is a Git client that resolves any revision to itself and counts | 25 | + | // pathsSHA is the commit the path list tests ask for. |
| 25 | - | // the lists it builds. | 26 | + | var pathsSHA = strings.Repeat("a", 40) |
| 26 | - | type pathsGit struct { | 27 | + | |
| 27 | - | ermiteconnect.GitServiceClient | 28 | + | // pathsBrowse resolves any full id to itself and lists a.go and b/c.go (or |
| 28 | - | lists atomic.Int32 | 29 | + | // paths) for the commit, as the anonymous visitor. lists is how many times |
| 29 | - | paths []string // the list; a.go and b/c.go when nil | 30 | + | // the list may be built: 0 means ListPaths must not be called. |
| 30 | - | } | 31 | + | func pathsBrowse(t testing.TB, lists int, paths []string) *mocks.BrowseService { |
| 31 | - | 32 | + | t.Helper() | |
| 32 | - | func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) { | 33 | + | browse := mocks.NewBrowseService(t) |
| 33 | - | return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: req.Msg.GetRevision()}), nil | 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 | |||
| 34 | } | 46 | } | ||
| 35 | 47 | ||||
| 36 | - | func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | 48 | + | func getPaths(srv *Server, sha string, hdr map[string]string) *httptest.ResponseRecorder { |
| 37 | - | g.lists.Add(1) | 49 | + | req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil) |
| 38 | - | paths := g.paths | 50 | + | for k, v := range hdr { |
| 39 | - | if paths == nil { | 51 | + | req.Header.Set(k, v) |
| 40 | - | paths = []string{"a.go", "b/c.go"} | |||
| 41 | } | 52 | } | ||
| 42 | - | return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: paths}), nil | 53 | + | rec := httptest.NewRecorder() |
| 54 | + | srv.ServeHTTP(rec, req) | |||
| 55 | + | return rec | |||
| 43 | } | 56 | } | ||
| 44 | 57 | ||||
| 45 | // A revalidation is answered after the access check and before the list is | 58 | // A revalidation is answered after the access check and before the list is | ||
| 46 | - | // loaded: it never builds. Each variant has its own validator. | 59 | + | // loaded: it never builds (the mock has no ListPaths to answer). Each variant |
| 60 | + | // has its own validator. | |||
| 47 | func TestPathsRevalidationDoesNotBuild(t *testing.T) { | 61 | func TestPathsRevalidationDoesNotBuild(t *testing.T) { | ||
| 48 | - | git := &pathsGit{} | 62 | + | t.Parallel() |
| 49 | - | srv, err := New(Config{ | 63 | + | gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"` |
| 50 | - | Repos: failing{}, Git: git, | 64 | + | tests := []struct{ name, ae, inm string }{ |
| 51 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 52 | - | }) | |||
| 53 | - | require.NoError(t, err) | |||
| 54 | - | sha := strings.Repeat("a", 40) | |||
| 55 | - | get := func(hdr map[string]string) *httptest.ResponseRecorder { | |||
| 56 | - | req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil) | |||
| 57 | - | for k, v := range hdr { | |||
| 58 | - | req.Header.Set(k, v) | |||
| 59 | - | } | |||
| 60 | - | rec := httptest.NewRecorder() | |||
| 61 | - | srv.ServeHTTP(rec, req) | |||
| 62 | - | return rec | |||
| 63 | - | } | |||
| 64 | - | gzTag, plainTag := `"paths-`+sha+`-gz"`, `"paths-`+sha+`"` | |||
| 65 | - | ||||
| 66 | - | for _, tt := range []struct{ name, ae, inm string }{ | |||
| 67 | {"gzip", "gzip", gzTag}, | 65 | {"gzip", "gzip", gzTag}, | ||
| 68 | {"identity", "", plainTag}, | 66 | {"identity", "", plainTag}, | ||
| 69 | {"weak", "gzip", "W/" + gzTag}, | 67 | {"weak", "gzip", "W/" + gzTag}, | ||
| 70 | {"any", "", "*"}, | 68 | {"any", "", "*"}, | ||
| 71 | - | } { | |||
| 72 | - | rec := get(map[string]string{"Accept-Encoding": tt.ae, "If-None-Match": tt.inm}) | |||
| 73 | - | assert.Equal(t, http.StatusNotModified, rec.Code, tt.name) | |||
| 74 | - | assert.Empty(t, rec.Body.String(), tt.name) | |||
| 75 | - | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), tt.name) | |||
| 76 | - | assert.Equal(t, "Accept-Encoding", rec.Header().Get("Vary"), tt.name) | |||
| 77 | - | assert.NotEmpty(t, rec.Header().Get("ETag"), tt.name) | |||
| 78 | } | 69 | } | ||
| 79 | - | assert.Zero(t, git.lists.Load(), "a 304 never builds") | 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)}) | |||
| 80 | 90 | ||||
| 81 | - | // The validator of the other variant does not match. | 91 | + | assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code) |
| 82 | - | assert.Equal(t, http.StatusOK, get(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) |
| 83 | - | assert.Equal(t, http.StatusOK, get(map[string]string{"If-None-Match": gzTag}).Code) | |||
| 84 | - | assert.EqualValues(t, 1, git.lists.Load(), "built once, then cached") | |||
| 85 | 93 | ||||
| 86 | - | rec := get(map[string]string{"Accept-Encoding": "gzip"}) | 94 | + | rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip"}) |
| 87 | assert.Equal(t, gzTag, rec.Header().Get("ETag")) | 95 | assert.Equal(t, gzTag, rec.Header().Get("ETag")) | ||
| 88 | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | 96 | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | ||
| 97 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |||
| 89 | zr, err := gzip.NewReader(rec.Body) | 98 | zr, err := gzip.NewReader(rec.Body) | ||
| 90 | require.NoError(t, err) | 99 | require.NoError(t, err) | ||
| 91 | out, err := io.ReadAll(zr) | 100 | out, err := io.ReadAll(zr) | ||
| 92 | require.NoError(t, err) | 101 | require.NoError(t, err) | ||
| 93 | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out)) | 102 | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out)) | ||
| 94 | 103 | ||||
| 95 | - | rec = get(nil) | 104 | + | rec = getPaths(srv, pathsSHA, nil) |
| 96 | assert.Equal(t, plainTag, rec.Header().Get("ETag")) | 105 | assert.Equal(t, plainTag, rec.Header().Get("ETag")) | ||
| 97 | assert.Empty(t, rec.Header().Get("Content-Encoding")) | 106 | assert.Empty(t, rec.Header().Get("Content-Encoding")) | ||
| 98 | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String()) | 107 | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String()) | ||
| 99 | } | 108 | } | ||
| 100 | 109 | ||||
| 101 | - | // The cached list holds no spare capacity. | 110 | + | // The commit is checked as the caller of the request, and only a full id that |
| 102 | - | func TestBuildPathsCachesATightBody(t *testing.T) { | 111 | + | // resolves to itself is served. |
| 103 | - | srv, err := New(Config{Repos: failing{}, Git: &pathsGit{}, PublicURL: "https://forge.example"}) | 112 | + | func TestPathsRefusals(t *testing.T) { |
| 104 | - | require.NoError(t, err) | 113 | + | t.Parallel() |
| 105 | - | body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40)) | 114 | + | other := strings.Repeat("b", 40) |
| 106 | - | require.NoError(t, err) | 115 | + | tests := []struct { |
| 107 | - | assert.Equal(t, len(body), cap(body)) | 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 | + | } | |||
| 108 | } | 145 | } | ||
| 109 | 146 | ||||
| 110 | - | func TestAcceptsGzip(t *testing.T) { | 147 | + | // The list is built as the caller of the request: a failure of the use case |
| 111 | - | for _, tt := range []struct { | 148 | + | // is the answer. |
| 112 | - | header string | 149 | + | func TestPathsListFailure(t *testing.T) { |
| 113 | - | want bool | 150 | + | t.Parallel() |
| 151 | + | tests := []struct { | |||
| 152 | + | name string | |||
| 153 | + | err error | |||
| 154 | + | want int | |||
| 114 | }{ | 155 | }{ | ||
| 115 | - | {"", false}, | 156 | + | {"not found", domain.ErrNotFound, http.StatusNotFound}, |
| 116 | - | {"gzip", true}, | 157 | + | {"other", errors.New("boom"), http.StatusInternalServerError}, |
| 117 | - | {"GZip", true}, | 158 | + | } |
| 118 | - | {"deflate, gzip", true}, | 159 | + | for _, tt := range tests { |
| 119 | - | {"deflate, br", false}, | 160 | + | t.Run(tt.name, func(t *testing.T) { |
| 120 | - | {"gzip;q=0", false}, | 161 | + | t.Parallel() |
| 121 | - | {"gzip; q=0", false}, | 162 | + | browse := pathsBrowse(t, 0, nil) |
| 122 | - | {"gzip;q=0.0", false}, | 163 | + | browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(domain.PathList{}, tt.err) |
| 123 | - | {"gzip ; q = 0", false}, | 164 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) |
| 124 | - | {"gzip;q=0.5", true}, | 165 | + | assert.Equal(t, tt.want, getPaths(srv, pathsSHA, nil).Code) |
| 125 | - | {"gzip;q=1", true}, | 166 | + | }) |
| 126 | - | {"identity, gzip;q=0", false}, | |||
| 127 | - | {"gzip;q=0, deflate", false}, | |||
| 128 | - | {"gzip;q=bad", true}, | |||
| 129 | - | {"x-gzip", false}, | |||
| 130 | - | // A wildcard is not read: the finder asks for gzip by name, and the | |||
| 131 | - | // plain body is always right. | |||
| 132 | - | {"*", false}, | |||
| 133 | - | } { | |||
| 134 | - | assert.Equal(t, tt.want, acceptsGzip(tt.header), "%q", tt.header) | |||
| 135 | } | 167 | } | ||
| 136 | } | 168 | } | ||
| 137 | 169 | ||||
| 138 | - | func TestEtagMatches(t *testing.T) { | 170 | + | // The cached list holds no spare capacity; the truncation is reported; a path |
| 139 | - | const tag = `"paths-x"` | 171 | + | // is not HTML, so "<", ">" and "&" are not escaped in the JSON. |
| 140 | - | for _, tt := range []struct { | 172 | + | func TestBuildPaths(t *testing.T) { |
| 141 | - | header string | 173 | + | t.Parallel() |
| 142 | - | want bool | 174 | + | tests := []struct { |
| 175 | + | name string | |||
| 176 | + | paths []string | |||
| 177 | + | truncated bool | |||
| 178 | + | want string | |||
| 143 | }{ | 179 | }{ | ||
| 144 | - | {"", false}, | 180 | + | {"plain", []string{"a.go", "b/c.go"}, false, `{"paths":["a.go","b/c.go"],"truncated":false}`}, |
| 145 | - | {tag, true}, | 181 | + | {"empty", nil, false, `{"paths":[],"truncated":false}`}, |
| 146 | - | {"W/" + tag, true}, | 182 | + | {"truncated", []string{"a.go"}, true, `{"paths":["a.go"],"truncated":true}`}, |
| 147 | - | {`"other"`, false}, | 183 | + | {"html characters", []string{"a&b/<c>.go"}, false, `{"paths":["a&b/<c>.go"],"truncated":false}`}, |
| 148 | - | {`"a", ` + tag, true}, | 184 | + | } |
| 149 | - | {`"a",` + tag + `,"b"`, true}, | 185 | + | for _, tt := range tests { |
| 150 | - | {`"a", W/` + tag, true}, | 186 | + | t.Run(tt.name, func(t *testing.T) { |
| 151 | - | {`"a", "b"`, false}, | 187 | + | t.Parallel() |
| 152 | - | {"*", true}, | 188 | + | browse := mocks.NewBrowseService(t) |
| 153 | - | {`"a", *`, true}, | 189 | + | browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return( |
| 154 | - | {`"paths-x-gz"`, false}, | 190 | + | domain.PathList{CommitID: objectID(t, pathsSHA), Paths: tt.paths, Truncated: tt.truncated}, nil) |
| 155 | - | {`w/` + tag, false}, | 191 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) |
| 156 | - | } { | 192 | + | ctx := domain.ContextWithPrincipal(context.Background(), domain.Anonymous()) |
| 157 | - | assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header) | 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 | + | }) | |||
| 158 | } | 202 | } | ||
| 159 | } | 203 | } | ||
| 160 | 204 | ||||
| 161 | - | // A path is not HTML: "<", ">" and "&" are not escaped in the JSON. | 205 | + | // A list that answers for another commit than the one asked is refused. |
| 162 | - | func TestBuildPathsDoesNotEscapeHTML(t *testing.T) { | 206 | + | func TestBuildPathsWrongCommit(t *testing.T) { |
| 163 | - | git := &pathsGit{paths: []string{"a&b/<c>.go"}} | 207 | + | t.Parallel() |
| 164 | - | srv, err := New(Config{Repos: failing{}, Git: git, PublicURL: "https://forge.example"}) | 208 | + | browse := mocks.NewBrowseService(t) |
| 165 | - | require.NoError(t, err) | 209 | + | browse.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( |
| 166 | - | body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40)) | 210 | + | domain.PathList{CommitID: objectID(t, strings.Repeat("b", 40))}, nil) |
| 167 | - | require.NoError(t, err) | 211 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) |
| 168 | - | zr, err := gzip.NewReader(bytes.NewReader(body)) | 212 | + | _, err := srv.buildPaths(context.Background(), repoName(t, "r"), pathsSHA) |
| 169 | - | require.NoError(t, err) | 213 | + | assert.ErrorIs(t, err, domain.ErrNotFound) |
| 170 | - | out, err := io.ReadAll(zr) | |||
| 171 | - | require.NoError(t, err) | |||
| 172 | - | assert.Contains(t, string(out), `"a&b/<c>.go"`) | |||
| 173 | } | 214 | } | ||
| 215 | + | ||||
| 216 | + | var _ ports.BrowseService = (*mocks.BrowseService)(nil) | |||
M internal/adapters/web/plaintext_test.go +6 -0
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | // Plain text stays text; only http(s) addresses become links, without the | 14 | // Plain text stays text; only http(s) addresses become links, without the | ||
| 15 | // punctuation that ends a sentence; invisible characters are marked. | 15 | // punctuation that ends a sentence; invisible characters are marked. | ||
| 16 | func TestPlainParts(t *testing.T) { | 16 | func TestPlainParts(t *testing.T) { | ||
| 17 | + | t.Parallel() | |||
| 17 | tests := []struct { | 18 | tests := []struct { | ||
| 18 | name, in string | 19 | name, in string | ||
| 19 | want []TextPart | 20 | want []TextPart | ||
| @@ -32,6 +33,7 @@ func TestPlainParts(t *testing.T) { | |||||
| 32 | } | 33 | } | ||
| 33 | for _, tt := range tests { | 34 | for _, tt := range tests { | ||
| 34 | t.Run(tt.name, func(t *testing.T) { | 35 | t.Run(tt.name, func(t *testing.T) { | ||
| 36 | + | t.Parallel() | |||
| 35 | assert.Equal(t, tt.want, plainParts(tt.in)) | 37 | assert.Equal(t, tt.want, plainParts(tt.in)) | ||
| 36 | }) | 38 | }) | ||
| 37 | } | 39 | } | ||
| @@ -39,12 +41,14 @@ func TestPlainParts(t *testing.T) { | |||||
| 39 | 41 | ||||
| 40 | // userText escapes the text and the address, and marks the link as untrusted. | 42 | // userText escapes the text and the address, and marks the link as untrusted. | ||
| 41 | func TestUserTextEscapes(t *testing.T) { | 43 | func TestUserTextEscapes(t *testing.T) { | ||
| 44 | + | t.Parallel() | |||
| 42 | var b bytes.Buffer | 45 | var b bytes.Buffer | ||
| 43 | require.NoError(t, userText(plainParts(`<b>x</b> & http://x.test/?a=1&b=2`)).Render(context.Background(), &b)) | 46 | require.NoError(t, userText(plainParts(`<b>x</b> & http://x.test/?a=1&b=2`)).Render(context.Background(), &b)) | ||
| 44 | assert.Equal(t, `<b>x</b> & <a href="http://x.test/?a=1&b=2" rel="noopener nofollow ugc">http://x.test/?a=1&b=2</a>`, b.String()) | 47 | assert.Equal(t, `<b>x</b> & <a href="http://x.test/?a=1&b=2" rel="noopener nofollow ugc">http://x.test/?a=1&b=2</a>`, b.String()) | ||
| 45 | } | 48 | } | ||
| 46 | 49 | ||||
| 47 | func TestParseOn(t *testing.T) { | 50 | func TestParseOn(t *testing.T) { | ||
| 51 | + | t.Parallel() | |||
| 48 | assert.Equal(t, &lineRef{side: "new", line: 12, path: "a/b:c.go"}, parseOn("new:12:a/b:c.go")) | 52 | assert.Equal(t, &lineRef{side: "new", line: 12, path: "a/b:c.go"}, parseOn("new:12:a/b:c.go")) | ||
| 49 | assert.Equal(t, &lineRef{side: "old", line: 1, path: "x"}, parseOn("old:1:x")) | 53 | assert.Equal(t, &lineRef{side: "old", line: 1, path: "x"}, parseOn("old:1:x")) | ||
| 50 | for _, s := range []string{"", "new", "new:12", "new:12:", "new:0:x", "new:-1:x", "new:x:y", "up:1:x", "new::x"} { | 54 | for _, s := range []string{"", "new", "new:12", "new:12:", "new:0:x", "new:-1:x", "new:x:y", "up:1:x", "new::x"} { | ||
| @@ -53,6 +57,7 @@ func TestParseOn(t *testing.T) { | |||||
| 53 | } | 57 | } | ||
| 54 | 58 | ||||
| 55 | func TestShellWord(t *testing.T) { | 59 | func TestShellWord(t *testing.T) { | ||
| 60 | + | t.Parallel() | |||
| 56 | assert.Equal(t, "feat/search", shellWord("feat/search")) | 61 | assert.Equal(t, "feat/search", shellWord("feat/search")) | ||
| 57 | assert.Equal(t, `'a b'`, shellWord("a b")) | 62 | assert.Equal(t, `'a b'`, shellWord("a b")) | ||
| 58 | assert.Equal(t, `'it'\''s'`, shellWord("it's")) | 63 | assert.Equal(t, `'it'\''s'`, shellWord("it's")) | ||
| @@ -61,6 +66,7 @@ func TestShellWord(t *testing.T) { | |||||
| 61 | 66 | ||||
| 62 | // The notes of a split row: a context line counts once. | 67 | // The notes of a split row: a context line counts once. | ||
| 63 | func TestSplitRowNotes(t *testing.T) { | 68 | func TestSplitRowNotes(t *testing.T) { | ||
| 69 | + | t.Parallel() | |||
| 64 | a, b := CommentView{ID: "1"}, CommentView{ID: "2"} | 70 | a, b := CommentView{ID: "1"}, CommentView{ID: "2"} | ||
| 65 | ctx := &DiffLine{Notes: []CommentView{a}} | 71 | ctx := &DiffLine{Notes: []CommentView{a}} | ||
| 66 | assert.Equal(t, []CommentView{a}, SplitRow{ctx, ctx}.Notes()) | 72 | assert.Equal(t, []CommentView{a}, SplitRow{ctx, ctx}.Notes()) | ||
M internal/adapters/web/raw.go +47 -24
| @@ -4,6 +4,8 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 7 | "mime" | 9 | "mime" | ||
| 8 | "net/http" | 10 | "net/http" | ||
| 9 | "path" | 11 | "path" | ||
| @@ -11,11 +13,16 @@ import ( | |||||
| 11 | "time" | 13 | "time" | ||
| 12 | 14 | ||||
| 13 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | ) | 18 | ) | ||
| 15 | 19 | ||||
| 16 | // rawTimeout bounds the stream of one file to the visitor. | 20 | // rawTimeout bounds the stream of one file to the visitor. | ||
| 17 | const rawTimeout = 5 * time.Minute | 21 | const rawTimeout = 5 * time.Minute | ||
| 18 | 22 | ||||
| 23 | + | // rawChunkSize is the size of the writes that stream a file to the visitor. | |||
| 24 | + | const rawChunkSize = 64 << 10 | |||
| 25 | + | ||||
| 19 | // rawCSP makes a raw file inert if a browser opens it as a document: it can | 26 | // rawCSP makes a raw file inert if a browser opens it as a document: it can | ||
| 20 | // load nothing and run nothing, whatever it contains. | 27 | // load nothing and run nothing, whatever it contains. | ||
| 21 | const rawCSP = "default-src 'none'; sandbox" | 28 | const rawCSP = "default-src 'none'; sandbox" | ||
| @@ -24,10 +31,17 @@ const rawCSP = "default-src 'none'; sandbox" | |||||
| 24 | // image types (by their bytes, never SVG) as images, and anything else as an | 31 | // image types (by their bytes, never SVG) as images, and anything else as an | ||
| 25 | // attachment of unknown type. A file is never served as HTML or SVG, so what | 32 | // attachment of unknown type. A file is never served as HTML or SVG, so what | ||
| 26 | // a repository holds cannot run in the origin of the forge. | 33 | // a repository holds cannot run in the origin of the forge. | ||
| 34 | + | // | |||
| 35 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here | |||
| 27 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | 36 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | ||
| 28 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | 37 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | ||
| 29 | defer cancel() | 38 | defer cancel() | ||
| 30 | - | b, t, repo, err := s.rawOpen(ctx, r) | 39 | + | t, repo, err := s.rawTarget(ctx, r) |
| 40 | + | if err != nil { | |||
| 41 | + | s.fail(ctx, w, r, err) | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | b, err := s.openBlob(ctx, repo, t) | |||
| 31 | if err != nil { | 45 | if err != nil { | ||
| 32 | s.fail(ctx, w, r, err) | 46 | s.fail(ctx, w, r, err) | ||
| 33 | return | 47 | return | ||
| @@ -44,7 +58,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 44 | h.Set("Content-Type", typ) | 58 | h.Set("Content-Type", typ) | ||
| 45 | h.Set("X-Content-Type-Options", "nosniff") | 59 | h.Set("X-Content-Type-Options", "nosniff") | ||
| 46 | h.Set("Content-Security-Policy", rawCSP) | 60 | h.Set("Content-Security-Policy", rawCSP) | ||
| 47 | - | h.Set("Content-Length", strconv.FormatInt(b.info.GetSize(), 10)) | 61 | + | h.Set("Content-Length", strconv.FormatInt(b.info.Size, 10)) |
| 48 | if attach { | 62 | if attach { | ||
| 49 | name := path.Base(t.path) | 63 | name := path.Base(t.path) | ||
| 50 | cd := mime.FormatMediaType("attachment", map[string]string{"filename": name}) | 64 | cd := mime.FormatMediaType("attachment", map[string]string{"filename": name}) | ||
| @@ -53,12 +67,15 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 53 | } | 67 | } | ||
| 54 | h.Set("Content-Disposition", cd) | 68 | h.Set("Content-Disposition", cd) | ||
| 55 | } | 69 | } | ||
| 56 | - | if etag := b.info.GetId(); etag != "" { | 70 | + | if etag := b.info.ID.String(); etag != "" { |
| 57 | h.Set("ETag", `"`+etag+`"`) | 71 | h.Set("ETag", `"`+etag+`"`) | ||
| 58 | } | 72 | } | ||
| 59 | // Revalidated every time, even at a full commit id: a repository can turn | 73 | // Revalidated every time, even at a full commit id: a repository can turn | ||
| 60 | // private, and a shared cache must not keep serving its files (AC2). The | 74 | // private, and a shared cache must not keep serving its files (AC2). The | ||
| 61 | - | // ETag keeps the revalidation cheap. | 75 | + | // ETag spares the body: the blob id comes with the metadata, before the |
| 76 | + | // content, and a match ends the answer there. No cheaper call gives the | |||
| 77 | + | // id of the blob at a path: a tree listing of its directory costs more | |||
| 78 | + | // than opening a small file. | |||
| 62 | cacheHeaders(ctx, h, "no-cache", sessionCache) | 79 | cacheHeaders(ctx, h, "no-cache", sessionCache) | ||
| 63 | if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) { | 80 | if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) { | ||
| 64 | w.WriteHeader(http.StatusNotModified) | 81 | w.WriteHeader(http.StatusNotModified) | ||
| @@ -72,35 +89,41 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 72 | return | 89 | return | ||
| 73 | } | 90 | } | ||
| 74 | b.buf = nil | 91 | b.buf = nil | ||
| 75 | - | for b.stream.Receive() { | 92 | + | if b.eof { |
| 76 | - | if _, err := w.Write(b.stream.Msg().GetChunk()); err != nil { | 93 | + | return |
| 94 | + | } | |||
| 95 | + | chunk := make([]byte, rawChunkSize) | |||
| 96 | + | for { | |||
| 97 | + | n, rerr := b.body.Read(chunk) | |||
| 98 | + | if n > 0 { | |||
| 99 | + | if _, err := w.Write(chunk[:n]); err != nil { | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | if errors.Is(rerr, io.EOF) { | |||
| 77 | return | 104 | return | ||
| 78 | } | 105 | } | ||
| 79 | - | } | 106 | + | if rerr != nil { |
| 80 | - | if err := b.stream.Err(); err != nil { | 107 | + | // Headers are gone: all that is left is to cut the answer short, |
| 81 | - | // Headers are gone: all that is left is to cut the answer short, so | 108 | + | // so that the client sees it is incomplete. |
| 82 | - | // that the client sees it is incomplete. | 109 | + | log := zerowrap.FromCtx(ctx) |
| 83 | - | log := zerowrap.FromCtx(ctx) | 110 | + | log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed") |
| 84 | - | log.Error().Err(err).Str("repo", repo).Str("path", r.URL.Path).Msg("raw stream failed") | 111 | + | panic(http.ErrAbortHandler) |
| 85 | - | panic(http.ErrAbortHandler) | 112 | + | } |
| 86 | } | 113 | } | ||
| 87 | } | 114 | } | ||
| 88 | 115 | ||||
| 89 | - | // rawOpen finds the file a raw URL designates and opens its stream. | 116 | + | // rawTarget finds the file a raw URL designates and the name of its repository. |
| 90 | - | func (s *Server) rawOpen(ctx context.Context, r *http.Request) (*blobStream, target, string, error) { | 117 | + | func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, domain.RepoName, error) { |
| 91 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 118 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 92 | if err != nil { | 119 | if err != nil { | ||
| 93 | - | return nil, target{}, "", err | 120 | + | return target{}, domain.RepoName{}, err |
| 94 | } | 121 | } | ||
| 95 | t, ok := d.locate(r.PathValue("rest")) | 122 | t, ok := d.locate(r.PathValue("rest")) | ||
| 96 | - | if !ok { | 123 | + | if !ok || t.path == "" { |
| 97 | - | return nil, target{}, "", errNotFound | 124 | + | return target{}, domain.RepoName{}, errNotFound |
| 98 | - | } | |||
| 99 | - | b, err := s.openBlob(ctx, d.name(), t) | |||
| 100 | - | if err != nil { | |||
| 101 | - | return nil, target{}, "", err | |||
| 102 | } | 125 | } | ||
| 103 | - | return b, t, d.name(), nil | 126 | + | return t, d.repo.Name, nil |
| 104 | } | 127 | } | ||
| 105 | 128 | ||||
| 106 | // rawType is the Content-Type of a file and whether it is an attachment. | 129 | // rawType is the Content-Type of a file and whether it is an attachment. | ||
| @@ -109,7 +132,7 @@ func rawType(b *blobStream) (typ string, attachment bool) { | |||||
| 109 | if img := sniffImage(b.buf); img != "" { | 132 | if img := sniffImage(b.buf); img != "" { | ||
| 110 | return img, false | 133 | return img, false | ||
| 111 | } | 134 | } | ||
| 112 | - | if !b.info.GetBinary() { | 135 | + | if !b.info.Binary { |
| 113 | return "text/plain; charset=utf-8", false | 136 | return "text/plain; charset=utf-8", false | ||
| 114 | } | 137 | } | ||
| 115 | return "application/octet-stream", true | 138 | return "application/octet-stream", true | ||
A internal/adapters/web/raw_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/http/httptest" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | blobID = "0123456789abcdef0123456789abcdef01234567" | |||
| 23 | + | mainTip = "c0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe" | |||
| 24 | + | blobBody = "hello" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // rawBrowse serves one text file, the only call raw makes besides the refs. | |||
| 28 | + | func rawBrowse(t *testing.T) *mocks.BrowseService { | |||
| 29 | + | t.Helper() | |||
| 30 | + | browse := mocks.NewBrowseService(t) | |||
| 31 | + | browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{ | |||
| 32 | + | Refs: []domain.Ref{{ | |||
| 33 | + | Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, | |||
| 34 | + | ID: objectID(t, mainTip), CommitID: objectID(t, mainTip), | |||
| 35 | + | }}, | |||
| 36 | + | }, nil).Maybe() | |||
| 37 | + | browse.EXPECT().OpenBlob(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything, mock.Anything).RunAndReturn( | |||
| 38 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 39 | + | if path.String() != "docs/a.txt" || rev.String() != mainTip { | |||
| 40 | + | return domain.BlobInfo{}, nil, domain.ErrNotFound | |||
| 41 | + | } | |||
| 42 | + | return domain.BlobInfo{ID: objectID(t, blobID), Path: "docs/a.txt", Size: int64(len(blobBody)), MediaType: "text/plain"}, | |||
| 43 | + | io.NopCloser(strings.NewReader(blobBody)), nil | |||
| 44 | + | }).Maybe() | |||
| 45 | + | return browse | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // A revalidation of an unchanged file ends with a 304 and no body. The blob is | |||
| 49 | + | // opened in every case: its id is only known from the metadata the use case | |||
| 50 | + | // returns with the content. | |||
| 51 | + | func TestRawRevalidation(t *testing.T) { | |||
| 52 | + | t.Parallel() | |||
| 53 | + | tests := []struct { | |||
| 54 | + | name string | |||
| 55 | + | url string | |||
| 56 | + | method string | |||
| 57 | + | ifNone string | |||
| 58 | + | want int | |||
| 59 | + | body string | |||
| 60 | + | }{ | |||
| 61 | + | {"no validator", "/r/raw/main/docs/a.txt", http.MethodGet, "", http.StatusOK, "hello"}, | |||
| 62 | + | {"matching validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotModified, ""}, | |||
| 63 | + | {"other validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"ffffffffffffffffffffffffffffffffffffffff"`, 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"}, | |||
| 68 | + | {"head", "/r/raw/main/docs/a.txt", http.MethodHead, "", http.StatusOK, ""}, | |||
| 69 | + | {"missing file", "/r/raw/main/docs/none.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""}, | |||
| 70 | + | {"directory", "/r/raw/main", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""}, | |||
| 71 | + | } | |||
| 72 | + | for _, tt := range tests { | |||
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 74 | + | t.Parallel() | |||
| 75 | + | repos := reposServing(t, publicRepo(t, "r")) | |||
| 76 | + | srv := newTestServer(t, Config{Repos: repos, Browse: rawBrowse(t)}) | |||
| 77 | + | req := httptest.NewRequest(tt.method, tt.url, nil) | |||
| 78 | + | if tt.ifNone != "" { | |||
| 79 | + | req.Header.Set("If-None-Match", tt.ifNone) | |||
| 80 | + | } | |||
| 81 | + | rec := httptest.NewRecorder() | |||
| 82 | + | srv.ServeHTTP(rec, req) | |||
| 83 | + | ||||
| 84 | + | assert.Equal(t, tt.want, rec.Code) | |||
| 85 | + | if tt.want != http.StatusNotFound { | |||
| 86 | + | assert.Equal(t, tt.body, rec.Body.String()) | |||
| 87 | + | } | |||
| 88 | + | if tt.want == http.StatusNotModified { | |||
| 89 | + | assert.Equal(t, `"`+blobID+`"`, rec.Header().Get("ETag")) | |||
| 90 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |||
| 91 | + | } | |||
| 92 | + | if tt.want == http.StatusOK { | |||
| 93 | + | assert.Equal(t, `"`+blobID+`"`, rec.Header().Get("ETag")) | |||
| 94 | + | assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy")) | |||
| 95 | + | } | |||
| 96 | + | }) | |||
| 97 | + | } | |||
| 98 | + | } | |||
M internal/adapters/web/refs.go +24 -22
| @@ -7,9 +7,7 @@ import ( | |||||
| 7 | "net/url" | 7 | "net/url" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | 9 | ||||
| 10 | - | "connectrpc.com/connect" | 10 | + | "bnema.dev/ermite/internal/domain" |
| 11 | - | ||||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | ) | 11 | ) | ||
| 14 | 12 | ||||
| 15 | // resolveRef finds the ref a URL path starts with and the path that follows. | 13 | // resolveRef finds the ref a URL path starts with and the path that follows. | ||
| @@ -17,7 +15,9 @@ import ( | |||||
| 17 | // that is the short name of a branch or tag wins. A branch wins over a tag of | 15 | // that is the short name of a branch or tag wins. A branch wins over a tag of | ||
| 18 | // the same name. rest has no leading slash and is "" when the path is the ref | 16 | // the same name. rest has no leading slash and is "" when the path is the ref | ||
| 19 | // alone. ok is false when no ref matches; the caller may then try a commit id. | 17 | // alone. ok is false when no ref matches; the caller may then try a commit id. | ||
| 20 | - | func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | 18 | + | // |
| 19 | + | //hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here | |||
| 20 | + | func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) { | |||
| 21 | names := refsByName(refs) | 21 | names := refsByName(refs) | ||
| 22 | p = strings.Trim(p, "/") | 22 | p = strings.Trim(p, "/") | ||
| 23 | for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") { | 23 | for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") { | ||
| @@ -25,21 +25,21 @@ func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | |||||
| 25 | return r, strings.TrimPrefix(p[end:], "/"), true | 25 | return r, strings.TrimPrefix(p[end:], "/"), true | ||
| 26 | } | 26 | } | ||
| 27 | } | 27 | } | ||
| 28 | - | return nil, "", false | 28 | + | return domain.Ref{}, "", false |
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | // refsByName indexes the branches and tags by short name. A branch wins over | 31 | // refsByName indexes the branches and tags by short name. A branch wins over | ||
| 32 | // a tag of the same name. | 32 | // a tag of the same name. | ||
| 33 | - | func refsByName(refs []*pb.Ref) map[string]*pb.Ref { | 33 | + | func refsByName(refs []domain.Ref) map[string]domain.Ref { |
| 34 | - | names := make(map[string]*pb.Ref, len(refs)) | 34 | + | names := make(map[string]domain.Ref, len(refs)) |
| 35 | for _, r := range refs { | 35 | for _, r := range refs { | ||
| 36 | - | k := r.GetKind() | 36 | + | k := r.Kind |
| 37 | - | if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG { | 37 | + | if k != domain.RefKindBranch && k != domain.RefKindTag { |
| 38 | continue | 38 | continue | ||
| 39 | } | 39 | } | ||
| 40 | - | cur, dup := names[r.GetShortName()] | 40 | + | cur, dup := names[r.ShortName] |
| 41 | - | if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH { | 41 | + | if !dup || cur.Kind != domain.RefKindBranch { |
| 42 | - | names[r.GetShortName()] = r | 42 | + | names[r.ShortName] = r |
| 43 | } | 43 | } | ||
| 44 | } | 44 | } | ||
| 45 | return names | 45 | return names | ||
| @@ -71,8 +71,8 @@ func repoHref(repo string, parts ...string) string { | |||||
| 71 | // target is what the URL of a tree, blob, raw or commits page designates: a | 71 | // target is what the URL of a tree, blob, raw or commits page designates: a | ||
| 72 | // commit, the name it was reached by, and a path in its tree. | 72 | // commit, the name it was reached by, and a path in its tree. | ||
| 73 | type target struct { | 73 | type target struct { | ||
| 74 | - | // rev is what the API is asked for: the id of the commit of a ref, or | 74 | + | // rev is what the use cases are asked for: the id of the commit of a ref, |
| 75 | - | // the commit id of the URL itself. | 75 | + | // or the commit id of the URL itself. |
| 76 | rev string | 76 | rev string | ||
| 77 | // name is how the URL spells the ref: a branch or a tag, or the commit id | 77 | // name is how the URL spells the ref: a branch or a tag, or the commit id | ||
| 78 | // when detached. | 78 | // when detached. | ||
| @@ -97,32 +97,34 @@ func (t target) commitID() string { | |||||
| 97 | // resolveCommit sets the full id of a target named by an abbreviated commit id, | 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 | 98 | // with one ResolveRevision. The page renders without it (it only lacks the file | ||
| 99 | // finder), so a failure is not one. | 99 | // finder), so a failure is not one. | ||
| 100 | - | func (s *Server) resolveCommit(ctx context.Context, repo string, t *target) { | 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) { | |||
| 101 | if !t.detached || isFullID(t.rev) { | 103 | if !t.detached || isFullID(t.rev) { | ||
| 102 | return | 104 | return | ||
| 103 | } | 105 | } | ||
| 104 | - | res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: t.rev})) | 106 | + | res, err := s.resolveRevision(ctx, repo, t.rev) |
| 105 | if err != nil { | 107 | if err != nil { | ||
| 106 | return | 108 | return | ||
| 107 | } | 109 | } | ||
| 108 | - | t.commit = res.Msg.GetCommitId() | 110 | + | t.commit = res.CommitID.String() |
| 109 | } | 111 | } | ||
| 110 | 112 | ||||
| 111 | // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | 113 | // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | ||
| 112 | // longest prefix of the path that is the short name of a branch or a tag | 114 | // longest prefix of the path that is the short name of a branch or a tag | ||
| 113 | // (names may contain "/"); a branch wins over a tag of the same name. An | 115 | // (names may contain "/"); a branch wins over a tag of the same name. An | ||
| 114 | // empty rest designates the head of the repository. Whether the commit or | 116 | // empty rest designates the head of the repository. Whether the commit or | ||
| 115 | - | // the path exists is for the API to say. | 117 | + | // the path exists is for the use cases to say. |
| 116 | func (d *repoData) locate(rest string) (target, bool) { | 118 | func (d *repoData) locate(rest string) (target, bool) { | ||
| 117 | rest = strings.Trim(rest, "/") | 119 | rest = strings.Trim(rest, "/") | ||
| 118 | if rest == "" { | 120 | if rest == "" { | ||
| 119 | if d.head == nil { | 121 | if d.head == nil { | ||
| 120 | return target{}, false | 122 | return target{}, false | ||
| 121 | } | 123 | } | ||
| 122 | - | return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true | 124 | + | return target{rev: d.head.CommitID.String(), name: d.head.ShortName}, true |
| 123 | } | 125 | } | ||
| 124 | - | if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" { | 126 | + | if ref, p, ok := resolveRef(d.allRefs(), rest); ok && !ref.CommitID.IsZero() { |
| 125 | - | return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true | 127 | + | return target{rev: ref.CommitID.String(), name: ref.ShortName, path: p}, true |
| 126 | } | 128 | } | ||
| 127 | first, p, _ := strings.Cut(rest, "/") | 129 | first, p, _ := strings.Cut(rest, "/") | ||
| 128 | if isCommitID(first) { | 130 | if isCommitID(first) { | ||
| @@ -152,7 +154,7 @@ func (d *repoData) isBranchHead(t target) bool { | |||||
| 152 | return false | 154 | return false | ||
| 153 | } | 155 | } | ||
| 154 | for _, b := range d.branches { | 156 | for _, b := range d.branches { | ||
| 155 | - | if b.GetShortName() == t.name && b.GetCommitId() == t.rev { | 157 | + | if b.ShortName == t.name && b.CommitID.String() == t.rev { |
| 156 | return true | 158 | return true | ||
| 157 | } | 159 | } | ||
| 158 | } | 160 | } | ||
M internal/adapters/web/refs_test.go +29 -12
| @@ -3,27 +3,42 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "crypto/sha1" // #nosec G505 -- ids for tests | |||
| 7 | + | "encoding/hex" | |||
| 6 | "testing" | 8 | "testing" | ||
| 7 | 9 | ||||
| 8 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 9 | 11 | ||||
| 10 | - | pb "bnema.dev/ermite-api/gen/ermite" | 12 | + | "bnema.dev/ermite/internal/domain" |
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | - | func ref(kind pb.RefKind, short string) *pb.Ref { | 15 | + | // commitOf is a commit id derived from a name, so that two refs of one name |
| 14 | - | return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short} | 16 | + | // share their commit. |
| 17 | + | func commitOf(name string) domain.ObjectID { | |||
| 18 | + | sum := sha1.Sum([]byte(name)) // #nosec G401 -- an id for a test, not a hash for security | |||
| 19 | + | id, err := domain.ParseObjectID(hex.EncodeToString(sum[:])) | |||
| 20 | + | if err != nil { | |||
| 21 | + | panic(err) | |||
| 22 | + | } | |||
| 23 | + | return id | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func ref(kind domain.RefKind, short string) domain.Ref { | |||
| 27 | + | id := commitOf(short) | |||
| 28 | + | return domain.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, ID: id, CommitID: id} | |||
| 15 | } | 29 | } | ||
| 16 | 30 | ||||
| 17 | // AC3: a ref with slashes is found by the longest matching prefix. | 31 | // AC3: a ref with slashes is found by the longest matching prefix. | ||
| 18 | func TestResolveRef(t *testing.T) { | 32 | func TestResolveRef(t *testing.T) { | ||
| 19 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | 33 | + | t.Parallel() |
| 20 | - | refs := []*pb.Ref{ | 34 | + | b, tag := domain.RefKindBranch, domain.RefKindTag |
| 35 | + | refs := []domain.Ref{ | |||
| 21 | ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"), | 36 | ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"), | ||
| 22 | - | ref(pb.RefKind_REF_KIND_HEAD, "main"), ref(tag, "main"), | 37 | + | ref(domain.RefKindHead, "main"), ref(tag, "main"), |
| 23 | } | 38 | } | ||
| 24 | tests := []struct { | 39 | tests := []struct { | ||
| 25 | path, ref, rest string | 40 | path, ref, rest string | ||
| 26 | - | kind pb.RefKind | 41 | + | kind domain.RefKind |
| 27 | ok bool | 42 | ok bool | ||
| 28 | }{ | 43 | }{ | ||
| 29 | {"main", "main", "", b, true}, | 44 | {"main", "main", "", b, true}, | ||
| @@ -33,25 +48,27 @@ func TestResolveRef(t *testing.T) { | |||||
| 33 | {"feature/other", "feature", "other", b, true}, | 48 | {"feature/other", "feature", "other", b, true}, | ||
| 34 | {"/feature/x/", "feature/x", "", b, true}, | 49 | {"/feature/x/", "feature/x", "", b, true}, | ||
| 35 | {"v1/rc/README", "v1/rc", "README", tag, true}, | 50 | {"v1/rc/README", "v1/rc", "README", tag, true}, | ||
| 36 | - | {"v1/README", "", "", 0, false}, | 51 | + | {"v1/README", "", "", "", false}, |
| 37 | - | {"nope/main", "", "", 0, false}, | 52 | + | {"nope/main", "", "", "", false}, |
| 38 | - | {"", "", "", 0, false}, | 53 | + | {"", "", "", "", false}, |
| 39 | } | 54 | } | ||
| 40 | for _, tt := range tests { | 55 | for _, tt := range tests { | ||
| 41 | t.Run(tt.path, func(t *testing.T) { | 56 | t.Run(tt.path, func(t *testing.T) { | ||
| 57 | + | t.Parallel() | |||
| 42 | r, rest, ok := resolveRef(refs, tt.path) | 58 | r, rest, ok := resolveRef(refs, tt.path) | ||
| 43 | assert.Equal(t, tt.ok, ok) | 59 | assert.Equal(t, tt.ok, ok) | ||
| 44 | if !ok { | 60 | if !ok { | ||
| 45 | return | 61 | return | ||
| 46 | } | 62 | } | ||
| 47 | - | assert.Equal(t, tt.ref, r.GetShortName()) | 63 | + | assert.Equal(t, tt.ref, r.ShortName) |
| 48 | - | assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name") | 64 | + | assert.Equal(t, tt.kind, r.Kind, "a branch wins over a tag of the same name") |
| 49 | assert.Equal(t, tt.rest, rest) | 65 | assert.Equal(t, tt.rest, rest) | ||
| 50 | }) | 66 | }) | ||
| 51 | } | 67 | } | ||
| 52 | } | 68 | } | ||
| 53 | 69 | ||||
| 54 | func TestRepoHref(t *testing.T) { | 70 | func TestRepoHref(t *testing.T) { | ||
| 71 | + | t.Parallel() | |||
| 55 | assert.Equal(t, "/demo", repoHref("demo")) | 72 | assert.Equal(t, "/demo", repoHref("demo")) | ||
| 56 | assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go")) | 73 | assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go")) | ||
| 57 | assert.Equal(t, "/demo/blob/main/%3Fq", repoHref("demo", "blob", "main", "", "?q")) | 74 | assert.Equal(t, "/demo/blob/main/%3Fq", repoHref("demo", "blob", "main", "", "?q")) | ||
M internal/adapters/web/refspage.go +16 -15
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/a-h/templ" | 10 | "github.com/a-h/templ" | ||
| 11 | 11 | ||||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | 12 | + | "bnema.dev/ermite/internal/domain" |
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // refs lists the branches and the tags of a repository with the commit each | 15 | // refs lists the branches and the tags of a repository with the commit each | ||
| @@ -33,27 +33,27 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 33 | // refsPage is the content of the refs page, which the live updates of the | 33 | // refsPage is the content of the refs page, which the live updates of the | ||
| 34 | // page render again. | 34 | // page render again. | ||
| 35 | func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | 35 | func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | ||
| 36 | - | commits := s.commitsOf(ctx, d.name(), d.allRefs()) | 36 | + | commits := s.commitsOf(ctx, d.repo.Name, d.allRefs()) |
| 37 | 37 | ||||
| 38 | - | def := d.repo.GetDefaultBranch() | 38 | + | def := d.repo.DefaultBranch |
| 39 | p := RefsPage{Default: def} | 39 | p := RefsPage{Default: def} | ||
| 40 | - | branches := append([]*pb.Ref(nil), d.branches...) | 40 | + | branches := append([]domain.Ref(nil), d.branches...) |
| 41 | sort.SliceStable(branches, func(i, j int) bool { | 41 | sort.SliceStable(branches, func(i, j int) bool { | ||
| 42 | - | a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def | 42 | + | a, b := branches[i].ShortName == def, branches[j].ShortName == def |
| 43 | return a && !b | 43 | return a && !b | ||
| 44 | }) | 44 | }) | ||
| 45 | for i, b := range branches { | 45 | for i, b := range branches { | ||
| 46 | - | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.GetShortName() == def, i == 0)) | 46 | + | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0)) |
| 47 | } | 47 | } | ||
| 48 | - | tags := append([]*pb.Ref(nil), d.tags...) | 48 | + | tags := append([]domain.Ref(nil), d.tags...) |
| 49 | sort.SliceStable(tags, func(i, j int) bool { | 49 | sort.SliceStable(tags, func(i, j int) bool { | ||
| 50 | - | return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at | 50 | + | return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at |
| 51 | }) | 51 | }) | ||
| 52 | for i, t := range tags { | 52 | for i, t := range tags { | ||
| 53 | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | 53 | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | ||
| 54 | } | 54 | } | ||
| 55 | if len(tags) > 0 { | 55 | if len(tags) > 0 { | ||
| 56 | - | p.Latest = tags[0].GetShortName() | 56 | + | p.Latest = tags[0].ShortName |
| 57 | } | 57 | } | ||
| 58 | if d.cut { | 58 | if d.cut { | ||
| 59 | p.Note = "this repository has more refs than are listed here." | 59 | p.Note = "this repository has more refs than are listed here." | ||
| @@ -61,15 +61,16 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||||
| 61 | return p | 61 | return p | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | 64 | + | func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { |
| 65 | - | c := commits[r.GetCommitId()] | 65 | + | id := r.CommitID.String() |
| 66 | + | c := commits[id] | |||
| 66 | row := RefRow{ | 67 | row := RefRow{ | ||
| 67 | - | Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()), | 68 | + | Name: r.ShortName, Href: repoHref(d.name(), "tree", r.ShortName), |
| 68 | - | Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt, | 69 | + | Short: short(id), Subject: c.subject, When: c.when, WhenAt: c.whenAt, |
| 69 | Default: isDefault, Cursor: cursor, | 70 | Default: isDefault, Cursor: cursor, | ||
| 70 | } | 71 | } | ||
| 71 | - | if r.GetCommitId() != "" { | 72 | + | if id != "" { |
| 72 | - | row.CHref = repoHref(d.name(), "commit", r.GetCommitId()) | 73 | + | row.CHref = repoHref(d.name(), "commit", id) |
| 73 | } | 74 | } | ||
| 74 | return row | 75 | return row | ||
| 75 | } | 76 | } | ||
M internal/adapters/web/repo.go +84 -53
| @@ -10,10 +10,8 @@ import ( | |||||
| 10 | "sync" | 10 | "sync" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | - | "connectrpc.com/connect" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 14 | - | "google.golang.org/protobuf/types/known/timestamppb" | 14 | + | "bnema.dev/ermite/internal/ports" |
| 15 | - | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 17 | ) | 15 | ) | ||
| 18 | 16 | ||||
| 19 | // maxRefs bounds the refs one page reads. A repository with more has its | 17 | // maxRefs bounds the refs one page reads. A repository with more has its | ||
| @@ -23,44 +21,69 @@ const maxRefs = 1000 | |||||
| 23 | // repoData is what the pages of one repository start from: its metadata and | 21 | // repoData is what the pages of one repository start from: its metadata and | ||
| 24 | // its refs, read once per request. | 22 | // its refs, read once per request. | ||
| 25 | type repoData struct { | 23 | type repoData struct { | ||
| 26 | - | repo *pb.Repo | 24 | + | repo ports.RepoView |
| 27 | - | branches []*pb.Ref | 25 | + | branches []domain.Ref |
| 28 | - | tags []*pb.Ref | 26 | + | tags []domain.Ref |
| 29 | // cut is true when the repository has more refs than maxRefs. | 27 | // cut is true when the repository has more refs than maxRefs. | ||
| 30 | cut bool | 28 | cut bool | ||
| 31 | // head is the branch the repository opens on: its default branch, or the | 29 | // head is the branch the repository opens on: its default branch, or the | ||
| 32 | // first one when the default does not exist. nil for an empty repository. | 30 | // first one when the default does not exist. nil for an empty repository. | ||
| 33 | - | head *pb.Ref | 31 | + | head *domain.Ref |
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // repoView reads a repository as the caller of the request may see it, with | |||
| 35 | + | // the Git state of its summary. A missing or private repository is | |||
| 36 | + | // domain.ErrNotFound. | |||
| 37 | + | func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) { | |||
| 38 | + | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return ports.RepoView{}, err | |||
| 41 | + | } | |||
| 42 | + | view, err := s.cfg.Repos.View(ctx, repo) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return ports.RepoView{}, err | |||
| 45 | + | } | |||
| 46 | + | if view.Settings.IsZero() { | |||
| 47 | + | view.Settings = domain.DefaultRepoSettings() | |||
| 48 | + | } | |||
| 49 | + | return view, nil | |||
| 34 | } | 50 | } | ||
| 35 | 51 | ||||
| 36 | // loadRepo reads a repository and its refs. A missing, private or invalidly | 52 | // loadRepo reads a repository and its refs. A missing, private or invalidly | ||
| 37 | - | // named repository is a not_found / invalid_argument error of the API, which | 53 | + | // named repository is a domain.ErrNotFound / invalid name error, which the |
| 38 | - | // the caller turns into the 404 page. | 54 | + | // caller turns into the 404 page. |
| 39 | - | func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | 55 | + | func (s *Server) loadRepo(ctx context.Context, rawName string) (*repoData, error) { |
| 40 | - | res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | 56 | + | name, err := domain.ParseRepoName(rawName) |
| 57 | + | if err != nil { | |||
| 58 | + | return nil, err | |||
| 59 | + | } | |||
| 60 | + | view, err := s.repoView(ctx, name) | |||
| 41 | if err != nil { | 61 | if err != nil { | ||
| 42 | return nil, err | 62 | return nil, err | ||
| 43 | } | 63 | } | ||
| 44 | - | d := &repoData{repo: res.Msg.GetRepo()} | 64 | + | d := &repoData{repo: view} |
| 65 | + | // Each page of ListRefs runs the whole for-each-ref again: the refs are | |||
| 66 | + | // asked in one page of up to maxRefs (the use case lowers it to its cap, | |||
| 67 | + | // and the token then leads to the rest). | |||
| 45 | token, n := "", 0 | 68 | token, n := "", 0 | ||
| 46 | for { | 69 | for { | ||
| 47 | - | page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{ | 70 | + | page, err := s.cfg.Browse.ListRefs(ctx, principal(ctx), name, ports.ListRefsParams{ |
| 48 | - | Repo: name, PageSize: pageSize, PageToken: token, | 71 | + | Kinds: []domain.RefKind{domain.RefKindBranch, domain.RefKindTag}, |
| 49 | - | Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG}, | 72 | + | PageSize: maxRefs, PageToken: token, |
| 50 | - | })) | 73 | + | }) |
| 51 | if err != nil { | 74 | if err != nil { | ||
| 52 | return nil, err | 75 | return nil, err | ||
| 53 | } | 76 | } | ||
| 54 | - | for _, r := range page.Msg.GetRefs() { | 77 | + | for _, r := range page.Refs { |
| 55 | - | switch r.GetKind() { | 78 | + | switch r.Kind { |
| 56 | - | case pb.RefKind_REF_KIND_BRANCH: | 79 | + | case domain.RefKindBranch: |
| 57 | d.branches = append(d.branches, r) | 80 | d.branches = append(d.branches, r) | ||
| 58 | - | case pb.RefKind_REF_KIND_TAG: | 81 | + | case domain.RefKindTag: |
| 59 | d.tags = append(d.tags, r) | 82 | d.tags = append(d.tags, r) | ||
| 60 | } | 83 | } | ||
| 61 | n++ | 84 | n++ | ||
| 62 | } | 85 | } | ||
| 63 | - | if token = page.Msg.GetNextPageToken(); token == "" { | 86 | + | if token = page.NextPageToken; token == "" { |
| 64 | break | 87 | break | ||
| 65 | } | 88 | } | ||
| 66 | if n >= maxRefs { | 89 | if n >= maxRefs { | ||
| @@ -68,24 +91,24 @@ func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | |||||
| 68 | break | 91 | break | ||
| 69 | } | 92 | } | ||
| 70 | } | 93 | } | ||
| 71 | - | for _, b := range d.branches { | 94 | + | for i := range d.branches { |
| 72 | - | if b.GetShortName() == d.repo.GetDefaultBranch() { | 95 | + | if d.branches[i].ShortName == d.repo.DefaultBranch { |
| 73 | - | d.head = b | 96 | + | d.head = &d.branches[i] |
| 74 | } | 97 | } | ||
| 75 | } | 98 | } | ||
| 76 | if d.head == nil && len(d.branches) > 0 { | 99 | if d.head == nil && len(d.branches) > 0 { | ||
| 77 | - | d.head = d.branches[0] | 100 | + | d.head = &d.branches[0] |
| 78 | } | 101 | } | ||
| 79 | return d, nil | 102 | return d, nil | ||
| 80 | } | 103 | } | ||
| 81 | 104 | ||||
| 82 | // allRefs is the branches then the tags. | 105 | // allRefs is the branches then the tags. | ||
| 83 | - | func (d *repoData) allRefs() []*pb.Ref { | 106 | + | func (d *repoData) allRefs() []domain.Ref { |
| 84 | - | return append(append([]*pb.Ref(nil), d.branches...), d.tags...) | 107 | + | return append(append([]domain.Ref(nil), d.branches...), d.tags...) |
| 85 | } | 108 | } | ||
| 86 | 109 | ||||
| 87 | // name is the repository name. | 110 | // name is the repository name. | ||
| 88 | - | func (d *repoData) name() string { return d.repo.GetName() } | 111 | + | func (d *repoData) name() string { return d.repo.Name.String() } |
| 89 | 112 | ||||
| 90 | // layout is the Layout of the pages of a repository: the sections, the ref | 113 | // layout is the Layout of the pages of a repository: the sections, the ref | ||
| 91 | // switcher on the head ref and the ":" targets. The caller sets the title, | 114 | // switcher on the head ref and the ":" targets. The caller sets the title, | ||
| @@ -93,11 +116,11 @@ func (d *repoData) name() string { return d.repo.GetName() } | |||||
| 93 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | 116 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | ||
| 94 | l := s.base() | 117 | l := s.base() | ||
| 95 | l.Repo = d.name() | 118 | l.Repo = d.name() | ||
| 96 | - | def := d.repo.GetDefaultBranch() | 119 | + | def := d.repo.DefaultBranch |
| 97 | if d.head != nil { | 120 | if d.head != nil { | ||
| 98 | - | def = d.head.GetShortName() | 121 | + | def = d.head.ShortName |
| 99 | l.Ref, l.RefPath = def, def | 122 | l.Ref, l.RefPath = def, def | ||
| 100 | - | l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId()) | 123 | + | l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String()) |
| 101 | l.setFinder(l.SHA, def) | 124 | l.setFinder(l.SHA, def) | ||
| 102 | } | 125 | } | ||
| 103 | mk := func(key, label, href, id string) Section { | 126 | mk := func(key, label, href, id string) Section { | ||
| @@ -120,12 +143,12 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||||
| 120 | } | 143 | } | ||
| 121 | if l.Ref != "" { | 144 | if l.Ref != "" { | ||
| 122 | for _, b := range d.branches { | 145 | for _, b := range d.branches { | ||
| 123 | - | it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head} | 146 | + | it := RefItem{Name: b.ShortName, Href: repoHref(d.name(), "tree", b.ShortName), Current: b.ShortName == d.head.ShortName} |
| 124 | l.Heads = append(l.Heads, it) | 147 | l.Heads = append(l.Heads, it) | ||
| 125 | l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href}) | 148 | l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href}) | ||
| 126 | } | 149 | } | ||
| 127 | for _, t := range d.tags { | 150 | for _, t := range d.tags { | ||
| 128 | - | it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())} | 151 | + | it := RefItem{Name: t.ShortName, Href: repoHref(d.name(), "tree", t.ShortName)} |
| 129 | l.Tags = append(l.Tags, it) | 152 | l.Tags = append(l.Tags, it) | ||
| 130 | l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href}) | 153 | l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href}) | ||
| 131 | } | 154 | } | ||
| @@ -151,7 +174,7 @@ type refCommit struct { | |||||
| 151 | 174 | ||||
| 152 | // commitsOf reads the head commit of each ref, a few at a time. A ref whose | 175 | // commitsOf reads the head commit of each ref, a few at a time. A ref whose | ||
| 153 | // commit cannot be read (a tag on a tree, say) has no entry. | 176 | // commit cannot be read (a tag on a tree, say) has no entry. | ||
| 154 | - | func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit { | 177 | + | func (s *Server) commitsOf(ctx context.Context, repo domain.RepoName, refs []domain.Ref) map[string]refCommit { |
| 155 | const workers = 8 | 178 | const workers = 8 | ||
| 156 | var ( | 179 | var ( | ||
| 157 | mu sync.Mutex | 180 | mu sync.Mutex | ||
| @@ -161,10 +184,10 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||||
| 161 | ) | 184 | ) | ||
| 162 | now := s.cfg.Now() | 185 | now := s.cfg.Now() | ||
| 163 | for _, r := range refs { | 186 | for _, r := range refs { | ||
| 164 | - | id := r.GetCommitId() | 187 | + | if r.CommitID.IsZero() { |
| 165 | - | if id == "" { | |||
| 166 | continue | 188 | continue | ||
| 167 | } | 189 | } | ||
| 190 | + | id := r.CommitID.String() | |||
| 168 | mu.Lock() | 191 | mu.Lock() | ||
| 169 | _, done := out[id] | 192 | _, done := out[id] | ||
| 170 | if !done { | 193 | if !done { | ||
| @@ -179,12 +202,12 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||||
| 179 | go func() { | 202 | go func() { | ||
| 180 | defer func() { <-sem; wg.Done() }() | 203 | defer func() { <-sem; wg.Done() }() | ||
| 181 | // By id, not by name: a branch and a tag may share a name. | 204 | // By id, not by name: a branch and a tag may share a name. | ||
| 182 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1})) | 205 | + | res, err := s.commitList(ctx, repo, id, "", 1, "") |
| 183 | var rc refCommit | 206 | var rc refCommit | ||
| 184 | - | if err == nil && len(res.Msg.GetCommits()) == 1 { | 207 | + | if err == nil && len(res.Commits) == 1 { |
| 185 | - | row := commitRow(repo, res.Msg.GetCommits()[0], now) | 208 | + | row := commitRow(repo.String(), res.Commits[0], now) |
| 186 | rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt | 209 | rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt | ||
| 187 | - | rc.at = res.Msg.GetCommits()[0].GetCommitter().GetTime().GetSeconds() | 210 | + | rc.at = unixSeconds(res.Commits[0].Committer.Time) |
| 188 | } | 211 | } | ||
| 189 | mu.Lock() | 212 | mu.Lock() | ||
| 190 | out[id] = rc | 213 | out[id] = rc | ||
| @@ -196,32 +219,40 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||||
| 196 | } | 219 | } | ||
| 197 | 220 | ||||
| 198 | // whenOf is the relative and the machine form of a time; both are "" when | 221 | // whenOf is the relative and the machine form of a time; both are "" when | ||
| 199 | - | // the time is unknown. | 222 | + | // the time is unknown (the zero time). |
| 200 | - | func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) { | 223 | + | func whenOf(now time.Time, t time.Time) (when, at string) { |
| 201 | - | if !ts.IsValid() { | 224 | + | if t.IsZero() { |
| 202 | return "", "" | 225 | return "", "" | ||
| 203 | } | 226 | } | ||
| 204 | - | return ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | 227 | + | return ago(now, t), rfc3339(t) |
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // unixSeconds is the Unix time of t in seconds, 0 for the zero time. | |||
| 231 | + | func unixSeconds(t time.Time) int64 { | |||
| 232 | + | if t.IsZero() { | |||
| 233 | + | return 0 | |||
| 234 | + | } | |||
| 235 | + | return t.Unix() | |||
| 205 | } | 236 | } | ||
| 206 | 237 | ||||
| 207 | // commitRow is the row every list of commits starts from: the short id, the | 238 | // commitRow is the row every list of commits starts from: the short id, the | ||
| 208 | // subject, the author and the date of the committer, all fit to display. The | 239 | // subject, the author and the date of the committer, all fit to display. The | ||
| 209 | // caller adds what is specific to its list. | 240 | // caller adds what is specific to its list. | ||
| 210 | - | func commitRow(repo string, c *pb.Commit, now time.Time) CommitRow { | 241 | + | func commitRow(repo string, c domain.Commit, now time.Time) CommitRow { |
| 211 | row := CommitRow{ | 242 | row := CommitRow{ | ||
| 212 | - | Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), | 243 | + | Short: short(c.ID.String()), Subject: visibleText(c.Summary), |
| 213 | - | CHref: repoHref(repo, "commit", c.GetId()), | 244 | + | CHref: repoHref(repo, "commit", c.ID.String()), |
| 214 | - | Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1, | 245 | + | Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1, |
| 215 | } | 246 | } | ||
| 216 | - | row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime()) | 247 | + | row.When, row.WhenAt = whenOf(now, c.Committer.Time) |
| 217 | return row | 248 | return row | ||
| 218 | } | 249 | } | ||
| 219 | 250 | ||||
| 220 | // namesAt maps commit ids to the names of the refs that designate them. | 251 | // namesAt maps commit ids to the names of the refs that designate them. | ||
| 221 | - | func namesAt(refs []*pb.Ref) map[string][]string { | 252 | + | func namesAt(refs []domain.Ref) map[string][]string { |
| 222 | m := map[string][]string{} | 253 | m := map[string][]string{} | ||
| 223 | for _, r := range refs { | 254 | for _, r := range refs { | ||
| 224 | - | m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName()) | 255 | + | m[r.CommitID.String()] = append(m[r.CommitID.String()], r.ShortName) |
| 225 | } | 256 | } | ||
| 226 | for _, v := range m { | 257 | for _, v := range m { | ||
| 227 | sort.Strings(v) | 258 | sort.Strings(v) | ||
| @@ -235,7 +266,7 @@ func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage | |||||
| 235 | l.Title = d.name() + " · " + s.instance | 266 | l.Title = d.name() + " · " + s.instance | ||
| 236 | l.Sections = nil | 267 | l.Sections = nil | ||
| 237 | l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" }) | 268 | l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" }) | ||
| 238 | - | branch := d.repo.GetDefaultBranch() | 269 | + | branch := d.repo.DefaultBranch |
| 239 | return l, StatePage{ | 270 | return l, StatePage{ | ||
| 240 | Code: "empty", Title: d.name() + " is empty", | 271 | Code: "empty", Title: d.name() + " is empty", | ||
| 241 | Lines: []string{"no commits yet. push a branch to create one."}, | 272 | Lines: []string{"no commits yet. push a branch to create one."}, | ||
A internal/adapters/web/rules_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // The speculation rules prefetch the light pages and leave the heavy ones | |||
| 14 | + | // (several Git reads each) to a click. | |||
| 15 | + | func TestSpeculationRulesSkipHeavyPages(t *testing.T) { | |||
| 16 | + | t.Parallel() | |||
| 17 | + | raw, err := staticFS.ReadFile("static/rules.json") | |||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | var rules struct { | |||
| 20 | + | Prefetch []struct { | |||
| 21 | + | Where struct { | |||
| 22 | + | And []struct { | |||
| 23 | + | Href string `json:"href_matches"` | |||
| 24 | + | Not *struct { | |||
| 25 | + | Href string `json:"href_matches"` | |||
| 26 | + | } `json:"not"` | |||
| 27 | + | } `json:"and"` | |||
| 28 | + | } `json:"where"` | |||
| 29 | + | Eagerness string `json:"eagerness"` | |||
| 30 | + | } `json:"prefetch"` | |||
| 31 | + | } | |||
| 32 | + | require.NoError(t, json.Unmarshal(raw, &rules)) | |||
| 33 | + | require.Len(t, rules.Prefetch, 1) | |||
| 34 | + | assert.Equal(t, "moderate", rules.Prefetch[0].Eagerness) | |||
| 35 | + | ||||
| 36 | + | var allowed string | |||
| 37 | + | excluded := map[string]bool{} | |||
| 38 | + | for _, c := range rules.Prefetch[0].Where.And { | |||
| 39 | + | if c.Href != "" { | |||
| 40 | + | allowed = c.Href | |||
| 41 | + | } | |||
| 42 | + | if c.Not != nil && c.Not.Href != "" { | |||
| 43 | + | excluded[c.Not.Href] = true | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | assert.Equal(t, "/*", allowed) | |||
| 47 | + | for _, p := range []string{ | |||
| 48 | + | "/-/*", "/*/raw/*", "/*/tree", "/*/tree/*", "/*/blob/*", "/*/commit/*", | |||
| 49 | + | "/*/commits", "/*/commits/*", "/*/compare/*", "/*/refs", "/*/merge-requests/*/since", | |||
| 50 | + | } { | |||
| 51 | + | assert.True(t, excluded[p], "%s is not prefetched", p) | |||
| 52 | + | } | |||
| 53 | + | for _, p := range []string{"/", "/*", "/*/merge-requests", "/*/merge-requests/*"} { | |||
| 54 | + | assert.False(t, excluded[p], "%s stays prefetched", p) | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/adapters/web/session.go +18 -29
| @@ -16,6 +16,8 @@ import ( | |||||
| 16 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 17 | 17 | ||||
| 18 | "bnema.dev/ermite/internal/clientlimit" | 18 | "bnema.dev/ermite/internal/clientlimit" | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | ) | 21 | ) | ||
| 20 | 22 | ||||
| 21 | // The owner's session on the web (ADR 0018). | 23 | // The owner's session on the web (ADR 0018). | ||
| @@ -29,7 +31,7 @@ import ( | |||||
| 29 | // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to | 31 | // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to | ||
| 30 | // the origin of the public URL, and a valid session, except the login POST, | 32 | // the origin of the public URL, and a valid session, except the login POST, | ||
| 31 | // which needs the link instead. The routes read the cookie and never the | 33 | // which needs the link instead. The routes read the cookie and never the | ||
| 32 | - | // Authorization header; the API does the opposite. | 34 | + | // Authorization header; the API does the opposite, it reads the header and no cookie. |
| 33 | 35 | ||||
| 34 | // CookieName is the session cookie. The __Host- prefix makes browsers accept | 36 | // CookieName is the session cookie. The __Host- prefix makes browsers accept | ||
| 35 | // it only with Secure, Path=/ and no Domain, and refuse it from a sibling | 37 | // it only with Secure, Path=/ and no Domain, and refuse it from a sibling | ||
| @@ -39,31 +41,6 @@ const CookieName = "__Host-ermite" | |||||
| 39 | // loginPrefix is the path of login links, up to the nonce. | 41 | // loginPrefix is the path of login links, up to the nonce. | ||
| 40 | const loginPrefix = "/-/login/" | 42 | const loginPrefix = "/-/login/" | ||
| 41 | 43 | ||||
| 42 | - | // ErrLinkRejected is what SessionAuth.Login returns for a link that is | |||
| 43 | - | // unknown, expired or already used. | |||
| 44 | - | var ErrLinkRejected = errors.New("login link rejected") | |||
| 45 | - | ||||
| 46 | - | // SessionAuth is the session side of the use cases, as the web needs it. | |||
| 47 | - | type SessionAuth interface { | |||
| 48 | - | // LoginLinkValid reports whether the link is pending, without using it. | |||
| 49 | - | LoginLinkValid(nonce string) bool | |||
| 50 | - | // Login uses the link and returns the session token and how long it | |||
| 51 | - | // lasts, or ErrLinkRejected. | |||
| 52 | - | Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error) | |||
| 53 | - | // Verify checks a session token. When it is valid it returns the context | |||
| 54 | - | // to serve the request with, which carries the owner as the caller of the | |||
| 55 | - | // API; when it is not, ok is false and err nil. An error means the check | |||
| 56 | - | // could not be made (the request then runs as anonymous). | |||
| 57 | - | Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error) | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | // FailureLimiter counts failed logins per client, with the failed | |||
| 61 | - | // authentications of the other routes. *clientlimit.Limiter fits. | |||
| 62 | - | type FailureLimiter interface { | |||
| 63 | - | Blocked(ctx context.Context, addr string) (bool, time.Duration) | |||
| 64 | - | Fail(ctx context.Context, addr string) | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | type signedInKey struct{} | 44 | type signedInKey struct{} | ||
| 68 | 45 | ||||
| 69 | // SignedIn reports whether the request carries a valid owner session. The | 46 | // SignedIn reports whether the request carries a valid owner session. The | ||
| @@ -150,7 +127,19 @@ func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Reque | |||||
| 150 | varyCookie(h) | 127 | varyCookie(h) | ||
| 151 | return r | 128 | return r | ||
| 152 | } | 129 | } | ||
| 153 | - | return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{})) | 130 | + | ctx = domain.ContextWithPrincipal(ctx, owner) |
| 131 | + | return r.WithContext(context.WithValue(ctx, signedInKey{}, struct{}{})) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // principal is the caller of the request, the one every use case call carries: | |||
| 135 | + | // the owner when the request has a valid session (withSession), anonymous | |||
| 136 | + | // otherwise. | |||
| 137 | + | func principal(ctx context.Context) domain.Principal { | |||
| 138 | + | p, ok := domain.PrincipalFromContext(ctx) | |||
| 139 | + | if !ok { | |||
| 140 | + | return domain.Anonymous() | |||
| 141 | + | } | |||
| 142 | + | return p | |||
| 154 | } | 143 | } | ||
| 155 | 144 | ||||
| 156 | // sessionStillValid checks again the cookie a long-lived request started | 145 | // sessionStillValid checks again the cookie a long-lived request started | ||
| @@ -275,12 +264,12 @@ func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) { | |||||
| 275 | nonce := r.PathValue("nonce") | 264 | nonce := r.PathValue("nonce") | ||
| 276 | var token string | 265 | var token string | ||
| 277 | var lifetime time.Duration | 266 | var lifetime time.Duration | ||
| 278 | - | err := ErrLinkRejected | 267 | + | err := ports.ErrLinkRejected |
| 279 | if validNonce(nonce) { | 268 | if validNonce(nonce) { | ||
| 280 | token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce) | 269 | token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce) | ||
| 281 | } | 270 | } | ||
| 282 | switch { | 271 | switch { | ||
| 283 | - | case errors.Is(err, ErrLinkRejected): | 272 | + | case errors.Is(err, ports.ErrLinkRejected): |
| 284 | s.cfg.Limiter.Fail(ctx, addr) | 273 | s.cfg.Limiter.Fail(ctx, addr) | ||
| 285 | s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage()) | 274 | s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage()) | ||
| 286 | return | 275 | return | ||
M internal/adapters/web/session_test.go +79 -45
| @@ -13,30 +13,58 @@ import ( | |||||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 16 | + | "github.com/stretchr/testify/mock" | |||
| 16 | "github.com/stretchr/testify/require" | 17 | "github.com/stretchr/testify/require" | ||
| 17 | 18 | ||||
| 18 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 19 | + | "bnema.dev/ermite/internal/domain" |
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 19 | ) | 22 | ) | ||
| 20 | 23 | ||||
| 21 | - | // fakeSessions accepts one token. | 24 | + | // nonceA is the one valid login link of the mocks. |
| 22 | - | type fakeSessions struct { | 25 | + | var nonceA = strings.Repeat("a", 43) |
| 23 | - | valid string | 26 | + | |
| 24 | - | err error | 27 | + | // newVerifier is a WebSessionService that accepts the session token valid, checked |
| 25 | - | used []string | 28 | + | // by verify when it is not nil, and that does not know any login link. A |
| 29 | + | // call that is not expected fails the test. | |||
| 30 | + | func newVerifier(t *testing.T, valid string, verify func(token string) (bool, error)) *mocks.WebSessionService { | |||
| 31 | + | t.Helper() | |||
| 32 | + | if verify == nil { | |||
| 33 | + | verify = func(token string) (bool, error) { return token == valid, nil } | |||
| 34 | + | } | |||
| 35 | + | m := mocks.NewWebSessionService(t) | |||
| 36 | + | m.EXPECT().Verify(mock.Anything, mock.Anything).RunAndReturn( | |||
| 37 | + | func(_ context.Context, token string) (domain.Principal, bool, error) { | |||
| 38 | + | ok, err := verify(token) | |||
| 39 | + | if !ok || err != nil { | |||
| 40 | + | return domain.Anonymous(), false, err | |||
| 41 | + | } | |||
| 42 | + | return domain.Owner(), true, nil | |||
| 43 | + | }).Maybe() | |||
| 44 | + | return m | |||
| 26 | } | 45 | } | ||
| 27 | 46 | ||||
| 28 | - | func (f *fakeSessions) LoginLinkValid(n string) bool { return n == strings.Repeat("a", 43) } | 47 | + | // expectLogin makes nonceA the one link of m, answering with the token valid. |
| 48 | + | func expectLogin(m *mocks.WebSessionService, valid string) { | |||
| 49 | + | m.EXPECT().LoginLinkValid(mock.Anything).RunAndReturn(func(n string) bool { return n == nonceA }).Maybe() | |||
| 50 | + | m.EXPECT().Login(mock.Anything, nonceA).Return(valid, time.Hour, nil).Maybe() | |||
| 51 | + | m.EXPECT().Login(mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Maybe() | |||
| 52 | + | } | |||
| 29 | 53 | ||||
| 30 | - | func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) { | 54 | + | // newSessions is a WebSessionService with one session token and one login link. |
| 31 | - | f.used = append(f.used, n) | 55 | + | func newSessions(t *testing.T, valid string) *mocks.WebSessionService { |
| 32 | - | if n != strings.Repeat("a", 43) { | 56 | + | t.Helper() |
| 33 | - | return "", 0, ErrLinkRejected | 57 | + | m := newVerifier(t, valid, nil) |
| 34 | - | } | 58 | + | expectLogin(m, valid) |
| 35 | - | return f.valid, time.Hour, nil | 59 | + | return m |
| 36 | } | 60 | } | ||
| 37 | 61 | ||||
| 38 | - | func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) { | 62 | + | // newBrokenSessions is a WebSessionService whose check cannot be made. |
| 39 | - | return ctx, f.err == nil && tok == f.valid, f.err | 63 | + | func newBrokenSessions(t *testing.T, valid string) *mocks.WebSessionService { |
| 64 | + | t.Helper() | |||
| 65 | + | m := newVerifier(t, valid, func(string) (bool, error) { return false, errors.New("kv down") }) | |||
| 66 | + | expectLogin(m, valid) | |||
| 67 | + | return m | |||
| 40 | } | 68 | } | ||
| 41 | 69 | ||||
| 42 | type countingLimiter struct{ fails int } | 70 | type countingLimiter struct{ fails int } | ||
| @@ -44,19 +72,18 @@ type countingLimiter struct{ fails int } | |||||
| 44 | func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 } | 72 | func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 } | ||
| 45 | func (l *countingLimiter) Fail(context.Context, string) { l.fails++ } | 73 | func (l *countingLimiter) Fail(context.Context, string) { l.fails++ } | ||
| 46 | 74 | ||||
| 47 | - | func newSessionServer(t *testing.T, sess SessionAuth, publicURL string) (*Server, *countingLimiter) { | 75 | + | func newSessionServer(t *testing.T, sess ports.WebSessionService, publicURL string) (*Server, *countingLimiter) { |
| 48 | t.Helper() | 76 | t.Helper() | ||
| 49 | lim := &countingLimiter{} | 77 | lim := &countingLimiter{} | ||
| 50 | - | srv, err := New(Config{ | 78 | + | srv := newTestServer(t, Config{ |
| 51 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 79 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), |
| 52 | - | PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1", | 80 | + | PublicURL: publicURL, Sessions: sess, Limiter: lim, |
| 53 | - | Sessions: sess, Limiter: lim, | |||
| 54 | }) | 81 | }) | ||
| 55 | - | require.NoError(t, err) | |||
| 56 | return srv, lim | 82 | return srv, lim | ||
| 57 | } | 83 | } | ||
| 58 | 84 | ||||
| 59 | func TestOriginOf(t *testing.T) { | 85 | func TestOriginOf(t *testing.T) { | ||
| 86 | + | t.Parallel() | |||
| 60 | for in, want := range map[string]string{ | 87 | for in, want := range map[string]string{ | ||
| 61 | "https://forge.example": "https://forge.example", | 88 | "https://forge.example": "https://forge.example", | ||
| 62 | "https://Forge.Example/": "https://forge.example", | 89 | "https://Forge.Example/": "https://forge.example", | ||
| @@ -75,6 +102,7 @@ func TestOriginOf(t *testing.T) { | |||||
| 75 | } | 102 | } | ||
| 76 | 103 | ||||
| 77 | func TestValidNonce(t *testing.T) { | 104 | func TestValidNonce(t *testing.T) { | ||
| 105 | + | t.Parallel() | |||
| 78 | assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a")) | 106 | assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a")) | ||
| 79 | for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} { | 107 | for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} { | ||
| 80 | assert.False(t, validNonce(n), n) | 108 | assert.False(t, validNonce(n), n) | ||
| @@ -84,7 +112,8 @@ func TestValidNonce(t *testing.T) { | |||||
| 84 | // The gate: a POST needs the Origin of the public URL, whatever Host says, and | 112 | // The gate: a POST needs the Origin of the public URL, whatever Host says, and | ||
| 85 | // a session unless it is the login. | 113 | // a session unless it is the login. | ||
| 86 | func TestGate(t *testing.T) { | 114 | func TestGate(t *testing.T) { | ||
| 87 | - | srv, lim := newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | 115 | + | t.Parallel() |
| 116 | + | srv, lim := newSessionServer(t, newSessions(t, "tok"), "https://forge.example") | |||
| 88 | do := func(path, origin, cookie string) int { | 117 | do := func(path, origin, cookie string) int { | ||
| 89 | req := httptest.NewRequest(http.MethodPost, path, nil) | 118 | req := httptest.NewRequest(http.MethodPost, path, nil) | ||
| 90 | req.Host = "forge.example" | 119 | req.Host = "forge.example" | ||
| @@ -122,14 +151,14 @@ func TestGate(t *testing.T) { | |||||
| 122 | // one the router matches, and on exactly loginPrefix + nonce. An encoded slash | 151 | // one the router matches, and on exactly loginPrefix + nonce. An encoded slash | ||
| 123 | // must not turn another route into a "login". | 152 | // must not turn another route into a "login". | ||
| 124 | func TestGateLoginExemptionIsExact(t *testing.T) { | 153 | func TestGateLoginExemptionIsExact(t *testing.T) { | ||
| 125 | - | sess := &fakeSessions{valid: "tok"} | 154 | + | t.Parallel() |
| 155 | + | sess := newVerifier(t, "tok", nil) // no Login is expected until the last request | |||
| 126 | lim := &countingLimiter{} | 156 | lim := &countingLimiter{} | ||
| 127 | - | srv, err := New(Config{ | 157 | + | srv := newTestServer(t, Config{ |
| 128 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 158 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), |
| 129 | - | MergeRequests: ermiteconnect.NewMergeRequestServiceClient(http.DefaultClient, "http://unused.invalid"), | 159 | + | MergeRequests: mocks.NewMergeRequestService(t), |
| 130 | - | PublicURL: "https://forge.example", Sessions: sess, Limiter: lim, | 160 | + | Sessions: sess, Limiter: lim, |
| 131 | }) | 161 | }) | ||
| 132 | - | require.NoError(t, err) | |||
| 133 | post := func(target, cookie string) int { | 162 | post := func(target, cookie string) int { | ||
| 134 | req := httptest.NewRequest(http.MethodPost, target, nil) | 163 | req := httptest.NewRequest(http.MethodPost, target, nil) | ||
| 135 | req.Header.Set("Origin", "https://forge.example") | 164 | req.Header.Set("Origin", "https://forge.example") | ||
| @@ -140,7 +169,7 @@ func TestGateLoginExemptionIsExact(t *testing.T) { | |||||
| 140 | srv.ServeHTTP(rec, req) | 169 | srv.ServeHTTP(rec, req) | ||
| 141 | return rec.Code | 170 | return rec.Code | ||
| 142 | } | 171 | } | ||
| 143 | - | nonce := strings.Repeat("a", 43) | 172 | + | nonce := nonceA |
| 144 | for _, target := range []string{ | 173 | for _, target := range []string{ | ||
| 145 | "/-%2Flogin%2Fx/merge-requests/1/close", | 174 | "/-%2Flogin%2Fx/merge-requests/1/close", | ||
| 146 | "/-%2flogin%2fx/merge-requests/1/close", | 175 | "/-%2flogin%2fx/merge-requests/1/close", | ||
| @@ -159,25 +188,28 @@ func TestGateLoginExemptionIsExact(t *testing.T) { | |||||
| 159 | } { | 188 | } { | ||
| 160 | assert.Equal(t, http.StatusForbidden, post(target, ""), target) | 189 | assert.Equal(t, http.StatusForbidden, post(target, ""), target) | ||
| 161 | } | 190 | } | ||
| 162 | - | assert.Empty(t, sess.used, "no link was used") | 191 | + | // No Login was expected so far: the mock fails the test on any call. |
| 192 | + | sess.AssertNotCalled(t, "Login", mock.Anything, mock.Anything) | |||
| 163 | 193 | ||||
| 194 | + | sess.EXPECT().Login(mock.Anything, nonce).Return("tok", time.Hour, nil).Once() | |||
| 164 | assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in") | 195 | assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in") | ||
| 165 | - | assert.Equal(t, []string{nonce}, sess.used) | |||
| 166 | } | 196 | } | ||
| 167 | 197 | ||||
| 168 | // A session check that cannot be made fails closed (anonymous) and keeps the | 198 | // A session check that cannot be made fails closed (anonymous) and keeps the | ||
| 169 | // cookie; a check that says no clears it. | 199 | // cookie; a check that says no clears it. | ||
| 170 | func TestWithSessionFailsClosed(t *testing.T) { | 200 | func TestWithSessionFailsClosed(t *testing.T) { | ||
| 171 | - | broken := &fakeSessions{valid: "tok", err: errors.New("kv down")} | 201 | + | t.Parallel() |
| 202 | + | broken := newBrokenSessions(t, "tok") | |||
| 172 | srv, _ := newSessionServer(t, broken, "https://forge.example") | 203 | srv, _ := newSessionServer(t, broken, "https://forge.example") | ||
| 173 | req := httptest.NewRequest(http.MethodGet, "/", nil) | 204 | req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 174 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | 205 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | ||
| 175 | rec := httptest.NewRecorder() | 206 | rec := httptest.NewRecorder() | ||
| 176 | r := srv.withSession(rec, req) | 207 | r := srv.withSession(rec, req) | ||
| 177 | assert.False(t, SignedIn(r.Context())) | 208 | assert.False(t, SignedIn(r.Context())) | ||
| 209 | + | assert.Equal(t, domain.Anonymous(), principal(r.Context())) | |||
| 178 | assert.Empty(t, rec.Header().Values("Set-Cookie")) | 210 | assert.Empty(t, rec.Header().Values("Set-Cookie")) | ||
| 179 | 211 | ||||
| 180 | - | srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | 212 | + | srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example") |
| 181 | req = httptest.NewRequest(http.MethodGet, "/", nil) | 213 | req = httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 182 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"}) | 214 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"}) | ||
| 183 | rec = httptest.NewRecorder() | 215 | rec = httptest.NewRecorder() | ||
| @@ -187,14 +219,18 @@ func TestWithSessionFailsClosed(t *testing.T) { | |||||
| 187 | 219 | ||||
| 188 | req = httptest.NewRequest(http.MethodGet, "/", nil) | 220 | req = httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 189 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | 221 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | ||
| 190 | - | assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context())) | 222 | + | r = srv.withSession(httptest.NewRecorder(), req) |
| 223 | + | assert.True(t, SignedIn(r.Context())) | |||
| 224 | + | assert.Equal(t, domain.Owner(), principal(r.Context()), "a valid session is the owner") | |||
| 225 | + | assert.Equal(t, domain.Anonymous(), principal(context.Background()), "no principal is anonymous") | |||
| 191 | } | 226 | } | ||
| 192 | 227 | ||||
| 193 | // GO-006: the answer to a request with an invalid cookie is not cached and | 228 | // GO-006: the answer to a request with an invalid cookie is not cached and | ||
| 194 | // varies on Cookie, and nothing later in the request undoes it: the page | 229 | // varies on Cookie, and nothing later in the request undoes it: the page | ||
| 195 | // writer, the raw file handler and the event stream keep it. | 230 | // writer, the raw file handler and the event stream keep it. | ||
| 196 | func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | 231 | func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | ||
| 197 | - | sess := &fakeSessions{valid: "tok"} | 232 | + | t.Parallel() |
| 233 | + | sess := newSessions(t, "tok") | |||
| 198 | srv, _ := newSessionServer(t, sess, "https://forge.example") | 234 | srv, _ := newSessionServer(t, sess, "https://forge.example") | ||
| 199 | 235 | ||||
| 200 | // withSession alone. | 236 | // withSession alone. | ||
| @@ -207,7 +243,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||||
| 207 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0") | 243 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0") | ||
| 208 | 244 | ||||
| 209 | // A check that cannot be made keeps the cookie and the headers as they are. | 245 | // A check that cannot be made keeps the cookie and the headers as they are. | ||
| 210 | - | broken, _ := newSessionServer(t, &fakeSessions{valid: "tok", err: errors.New("kv down")}, "https://forge.example") | 246 | + | broken, _ := newSessionServer(t, newBrokenSessions(t, "tok"), "https://forge.example") |
| 211 | rec = httptest.NewRecorder() | 247 | rec = httptest.NewRecorder() | ||
| 212 | broken.withSession(rec, req) | 248 | broken.withSession(rec, req) | ||
| 213 | assert.Empty(t, rec.Header().Values("Cache-Control")) | 249 | assert.Empty(t, rec.Header().Values("Cache-Control")) | ||
| @@ -226,7 +262,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||||
| 226 | rec := httptest.NewRecorder() | 262 | rec := httptest.NewRecorder() | ||
| 227 | srv.ServeHTTP(rec, req) | 263 | srv.ServeHTTP(rec, req) | ||
| 228 | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target) | 264 | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target) | ||
| 229 | - | assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"), target) | 265 | + | assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target) |
| 230 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target) | 266 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target) | ||
| 231 | } | 267 | } | ||
| 232 | 268 | ||||
| @@ -251,23 +287,21 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||||
| 251 | 287 | ||||
| 252 | // Without sessions configured there is no login route and no cookie is read. | 288 | // Without sessions configured there is no login route and no cookie is read. | ||
| 253 | func TestNoSessionsNoRoutes(t *testing.T) { | 289 | func TestNoSessionsNoRoutes(t *testing.T) { | ||
| 254 | - | srv, err := New(Config{ | 290 | + | t.Parallel() |
| 255 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 291 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)}) |
| 256 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 257 | - | }) | |||
| 258 | - | require.NoError(t, err) | |||
| 259 | rec := httptest.NewRecorder() | 292 | rec := httptest.NewRecorder() | ||
| 260 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil)) | 293 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil)) | ||
| 261 | assert.Equal(t, 404, rec.Code) | 294 | assert.Equal(t, 404, rec.Code) | ||
| 262 | 295 | ||||
| 263 | - | _, err = New(Config{ | 296 | + | _, err := New(Config{ |
| 264 | - | Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 297 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), |
| 265 | - | PublicURL: "https://forge.example", Sessions: &fakeSessions{}, | 298 | + | PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t), |
| 266 | }) | 299 | }) | ||
| 267 | assert.Error(t, err, "sessions need a limiter") | 300 | assert.Error(t, err, "sessions need a limiter") | ||
| 268 | } | 301 | } | ||
| 269 | 302 | ||||
| 270 | func TestLogPathHidesLinks(t *testing.T) { | 303 | func TestLogPathHidesLinks(t *testing.T) { | ||
| 304 | + | t.Parallel() | |||
| 271 | assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43))) | 305 | assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43))) | ||
| 272 | assert.Equal(t, "/demo/tree", logPath("/demo/tree")) | 306 | assert.Equal(t, "/demo/tree", logPath("/demo/tree")) | ||
| 273 | for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} { | 307 | for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} { | ||
M internal/adapters/web/shell.templ +8 -8
| @@ -16,6 +16,7 @@ templ Shell(l Layout, rail templ.Component) { | |||||
| 16 | <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/> | 16 | <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/> | ||
| 17 | <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/> | 17 | <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/> | ||
| 18 | <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/> | 18 | <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/> | ||
| 19 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'/> | |||
| 19 | <script src={ l.Assets.HTMX } defer></script> | 20 | <script src={ l.Assets.HTMX } defer></script> | ||
| 20 | <script src={ l.Assets.Live } defer></script> | 21 | <script src={ l.Assets.Live } defer></script> | ||
| 21 | <script src={ l.Assets.SSE } defer></script> | 22 | <script src={ l.Assets.SSE } defer></script> | ||
| @@ -92,34 +93,33 @@ templ Header(l Layout) { | |||||
| 92 | } | 93 | } | ||
| 93 | <div class="hdr-acts"> | 94 | <div class="hdr-acts"> | ||
| 94 | if l.Ref != "" { | 95 | if l.Ref != "" { | ||
| 95 | - | <button | 96 | + | <a |
| 96 | - | type="button" | |||
| 97 | class="btn ref" | 97 | class="btn ref" | ||
| 98 | - | aria-haspopup="dialog" | 98 | + | href={ templ.SafeURL(repoHref(l.Repo, "refs")) } |
| 99 | - | aria-controls="panel-refs" | 99 | + | data-fire="refs" |
| 100 | aria-keyshortcuts="b" | 100 | aria-keyshortcuts="b" | ||
| 101 | title="switch branch or tag (b)" | 101 | title="switch branch or tag (b)" | ||
| 102 | - | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))" | |||
| 103 | > | 102 | > | ||
| 104 | <span class="ico i-branch" aria-hidden="true"></span> | 103 | <span class="ico i-branch" aria-hidden="true"></span> | ||
| 105 | <span class="ref-name">{ l.Ref }</span> | 104 | <span class="ref-name">{ l.Ref }</span> | ||
| 106 | <span class="sha">{ l.Short }</span> | 105 | <span class="sha">{ l.Short }</span> | ||
| 107 | - | </button> | 106 | + | </a> |
| 108 | } | 107 | } | ||
| 109 | <button | 108 | <button | ||
| 110 | type="button" | 109 | type="button" | ||
| 111 | class="btn search" | 110 | class="btn search" | ||
| 111 | + | data-js | |||
| 112 | aria-haspopup="dialog" | 112 | aria-haspopup="dialog" | ||
| 113 | aria-controls="panel-cmd" | 113 | aria-controls="panel-cmd" | ||
| 114 | aria-keyshortcuts="Control+K :" | 114 | aria-keyshortcuts="Control+K :" | ||
| 115 | title="go to a section, ref, file or repo (ctrl k or :)" | 115 | title="go to a section, ref, file or repo (ctrl k or :)" | ||
| 116 | - | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))" | 116 | + | data-fire="cmd" |
| 117 | > | 117 | > | ||
| 118 | <span class="ico i-search" aria-hidden="true"></span> | 118 | <span class="ico i-search" aria-hidden="true"></span> | ||
| 119 | <span class="search-label">Go to…</span> | 119 | <span class="search-label">Go to…</span> | ||
| 120 | <kbd aria-hidden="true">ctrl k</kbd> | 120 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 121 | </button> | 121 | </button> | ||
| 122 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 122 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 123 | if SignedIn(ctx) { | 123 | if SignedIn(ctx) { | ||
| 124 | <form class="owner" method="post" action="/-/logout"> | 124 | <form class="owner" method="post" action="/-/logout"> | ||
| 125 | <span class="owner-badge" title="signed in as the owner">owner</span> | 125 | <span class="owner-badge" title="signed in as the owner">owner</span> | ||
M internal/adapters/web/shell_templ.go +82 -69 generated file: collapsed
M internal/adapters/web/static/css/10-base.css +2 -0
| @@ -70,6 +70,8 @@ | |||||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | @layer utilities { | 72 | @layer utilities { | ||
| 73 | + | /* what does nothing without scripts waits for ermite.js, which sets html.js */ | |||
| 74 | + | html:not(.js) [data-js], html:not(.js) .filter { display: none; } | |||
| 73 | .dim { color: var(--ink-dim); } | 75 | .dim { color: var(--ink-dim); } | ||
| 74 | .mute { color: var(--ink-mute); } | 76 | .mute { color: var(--ink-mute); } | ||
| 75 | .nowrap { white-space: nowrap; } | 77 | .nowrap { white-space: nowrap; } | ||
M internal/adapters/web/static/css/20-shell.css +6 -1
| @@ -169,7 +169,12 @@ | |||||
| 169 | & > :empty { display: none; } | 169 | & > :empty { display: none; } | ||
| 170 | & .hint-live { color: var(--red); } | 170 | & .hint-live { color: var(--red); } | ||
| 171 | } | 171 | } | ||
| 172 | - | .hint:not(:has(> :not(:empty))) { display: none; } | 172 | + | /* empty, the badge is out of sight but stays a status region (display: none |
| 173 | + | would drop it from the accessibility tree and its next message would not be | |||
| 174 | + | announced) */ | |||
| 175 | + | .hint:not(:has(> :not(:empty))) { | |||
| 176 | + | width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none; | |||
| 177 | + | } | |||
| 173 | 178 | ||||
| 174 | @media (max-width: 1080px) { | 179 | @media (max-width: 1080px) { | ||
| 175 | /* the rail goes under the content, in two columns while there is room */ | 180 | /* the rail goes under the content, in two columns while there is room */ | ||
M internal/adapters/web/static/css/80-live.css +7 -4
| @@ -1,9 +1,12 @@ | |||||
| 1 | @layer components { | 1 | @layer components { | ||
| 2 | - | /* live updates: the banner of new commits, an empty slot takes no room */ | 2 | + | /* live updates: a slot is a status region that stays in the page; its banner |
| 3 | - | #commits-head:empty { display: none; } | 3 | + | comes and goes as its content. Empty, it is out of the way but still in the |
| 4 | - | .live-banner { | 4 | + | accessibility tree (display: none would drop the region, and the next banner |
| 5 | + | would not be announced). */ | |||
| 6 | + | .live-slot:empty { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } | |||
| 7 | + | .live-slot:not(:empty) { | |||
| 5 | padding: var(--s3) var(--s4); font-weight: 700; | 8 | padding: var(--s3) var(--s4); font-weight: 700; | ||
| 6 | background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s); | 9 | background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s); | ||
| 7 | } | 10 | } | ||
| 8 | - | .live-banner a { color: var(--ink-hi); } | 11 | + | .live-slot a { color: var(--ink-hi); } |
| 9 | } | 12 | } | ||
M internal/adapters/web/static/js/ermite.js +310 -17
| @@ -1,55 +1,322 @@ | |||||
| 1 | - | // ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine | 1 | + | // ermite.js: the behaviour of the pages that htmx and hx-live do not give. |
| 2 | - | // ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused. | 2 | + | // |
| 3 | - | // It only translates keys into DOM events named "k-<name>" on <body>; every reaction | 3 | + | // 1. Keys. A global key *sequence* state machine ("g" then "h") and the |
| 4 | - | // (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live. | 4 | + | // keystrokes aimed at inputs cannot be declared with hx-on. The keydown |
| 5 | + | // handler only translates keys into DOM events named "k-<name>" on <body>; | |||
| 6 | + | // keys.templ binds each of them (hx-on, one line each) to a function of | |||
| 7 | + | // window.ermite below, which does the work: row cursor, dialogs, filters, | |||
| 8 | + | // diff layout, permalink. | |||
| 9 | + | // 2. Clicks on [data-fire] elements fire the same events (header buttons). | |||
| 10 | + | // 3. ermiteLive applies the fragments of the event stream (see events.go). | |||
| 11 | + | // 4. The file of the diff in view is marked in the table of contents. | |||
| 12 | + | // 5. A handler that fails is shown in the hint badge. | |||
| 5 | (() => { | 13 | (() => { | ||
| 14 | + | // Marks the page as scripted: what does nothing without scripts (the filters, | |||
| 15 | + | // the "go to" and help buttons) is hidden until this class is set (see the | |||
| 16 | + | // utilities of 10-base.css). | |||
| 17 | + | document.documentElement.classList.add('js'); | |||
| 18 | + | ||||
| 19 | + | const $ = (sel, root = document) => root.querySelector(sel); | |||
| 20 | + | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |||
| 21 | + | const body = () => document.body; | |||
| 22 | + | ||||
| 23 | + | // flash shows a message in the hint badge (body[data-flash], a status | |||
| 24 | + | // region), then clears it unless another message came in the meantime. | |||
| 25 | + | let flashTimer = 0; | |||
| 26 | + | const flash = (msg, ms = 2000) => { | |||
| 27 | + | body().dataset.flash = msg; | |||
| 28 | + | clearTimeout(flashTimer); | |||
| 29 | + | flashTimer = setTimeout(() => (body().dataset.flash = ''), ms); | |||
| 30 | + | }; | |||
| 31 | + | ||||
| 32 | + | const visibleRows = () => $$('[data-rows] > :not([hidden])'); | |||
| 33 | + | // setCursor puts data-cursor on el and takes it from the others. | |||
| 34 | + | const setCursor = (el, others) => { | |||
| 35 | + | for (const o of others) if (o !== el) o.removeAttribute('data-cursor'); | |||
| 36 | + | el.setAttribute('data-cursor', ''); | |||
| 37 | + | }; | |||
| 38 | + | const scrollPage = (to) => { | |||
| 39 | + | scrollTo(0, to); | |||
| 40 | + | $('#main')?.scrollTo(0, to); | |||
| 41 | + | }; | |||
| 42 | + | ||||
| 43 | + | const ermite = { | |||
| 44 | + | fire(name) { | |||
| 45 | + | body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |||
| 46 | + | }, | |||
| 47 | + | ||||
| 48 | + | // Row cursor: j / k. | |||
| 49 | + | move(d) { | |||
| 50 | + | if ($('dialog[open]')) return; | |||
| 51 | + | const rows = visibleRows(); | |||
| 52 | + | if (!rows.length) { | |||
| 53 | + | scrollBy(0, d * 180); | |||
| 54 | + | $('#main')?.scrollBy(0, d * 180); | |||
| 55 | + | return; | |||
| 56 | + | } | |||
| 57 | + | const i = rows.findIndex((r) => r.hasAttribute('data-cursor')); | |||
| 58 | + | const j = Math.max(0, Math.min(rows.length - 1, i + d)); | |||
| 59 | + | setCursor(rows[j], $$('[data-rows] > [data-cursor]')); | |||
| 60 | + | rows[j].scrollIntoView({ block: 'nearest' }); | |||
| 61 | + | const mark = j + 1 + '/' + rows.length; | |||
| 62 | + | body().dataset.nav = mark; | |||
| 63 | + | setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500); | |||
| 64 | + | }, | |||
| 65 | + | // gg / G: first or last row, or the top or bottom of the page. | |||
| 66 | + | edge(last) { | |||
| 67 | + | const rows = visibleRows(); | |||
| 68 | + | const row = last ? rows.at(-1) : rows[0]; | |||
| 69 | + | if (!row) return scrollPage(last ? 1e9 : 0); | |||
| 70 | + | setCursor(row, $$('[data-rows] > [data-cursor]')); | |||
| 71 | + | row.scrollIntoView({ block: 'nearest' }); | |||
| 72 | + | }, | |||
| 73 | + | open() { | |||
| 74 | + | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | |||
| 75 | + | }, | |||
| 76 | + | focusFilter() { | |||
| 77 | + | $('[data-filter]')?.focus(); | |||
| 78 | + | }, | |||
| 79 | + | go(sel) { | |||
| 80 | + | $(sel)?.click(); | |||
| 81 | + | }, | |||
| 82 | + | tab(key) { | |||
| 83 | + | $(`.tabs [data-key=${key}]`)?.click(); | |||
| 84 | + | }, | |||
| 85 | + | ||||
| 86 | + | // Dialogs. | |||
| 87 | + | closeDialogs() { | |||
| 88 | + | for (const d of $$('dialog[open]')) d.close(); | |||
| 89 | + | }, | |||
| 90 | + | dialog(id, focusId) { | |||
| 91 | + | const d = document.getElementById(id); | |||
| 92 | + | if (!d) return; | |||
| 93 | + | ermite.closeDialogs(); | |||
| 94 | + | d.showModal(); | |||
| 95 | + | if (focusId) document.getElementById(focusId)?.focus(); | |||
| 96 | + | }, | |||
| 97 | + | ||||
| 98 | + | // filter hides the rows that do not contain the text of an input. The | |||
| 99 | + | // input says which rows (data-filter, or data-pick for a list without a | |||
| 100 | + | // cursor) and, optionally, the groups to hide when they have no row left | |||
| 101 | + | // (data-groups). The cursor goes to the first row left. | |||
| 102 | + | filter(input) { | |||
| 103 | + | const rows = ermite.hide(input); | |||
| 104 | + | if (input.dataset.filter) { | |||
| 105 | + | const first = rows.find((r) => !r.hidden); | |||
| 106 | + | for (const r of rows) r.toggleAttribute('data-cursor', r === first); | |||
| 107 | + | } | |||
| 108 | + | }, | |||
| 109 | + | // hide applies the text of a filter input to its rows and groups, without | |||
| 110 | + | // moving the cursor, and returns the rows. | |||
| 111 | + | hide(input) { | |||
| 112 | + | const t = input.value.toLowerCase(); | |||
| 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); | |||
| 118 | + | for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t); | |||
| 119 | + | if (input.dataset.groups) { | |||
| 120 | + | for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); | |||
| 121 | + | } | |||
| 122 | + | return rows; | |||
| 123 | + | }, | |||
| 124 | + | // The "go to" palette. | |||
| 125 | + | cmdFilter(input) { | |||
| 126 | + | const t = input.value.toLowerCase().trim(); | |||
| 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]'); | |||
| 130 | + | for (const li of items) li.hidden = !li.dataset.k.includes(t); | |||
| 131 | + | const first = items.find((li) => !li.hidden); | |||
| 132 | + | if (first) setCursor(first, $$('#cmd-list > li')); | |||
| 133 | + | }, | |||
| 134 | + | cmdKey(e) { | |||
| 135 | + | const vis = $$('#cmd-list > li:not([hidden])'); | |||
| 136 | + | const i = vis.findIndex((li) => li.hasAttribute('data-cursor')); | |||
| 137 | + | const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; | |||
| 138 | + | if (step) { | |||
| 139 | + | e.preventDefault(); | |||
| 140 | + | const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; | |||
| 141 | + | if (n) { | |||
| 142 | + | setCursor(n, $$('#cmd-list > li')); | |||
| 143 | + | n.scrollIntoView({ block: 'nearest' }); | |||
| 144 | + | } | |||
| 145 | + | } else if (e.key === 'Enter') { | |||
| 146 | + | e.preventDefault(); | |||
| 147 | + | vis[Math.max(i, 0)]?.querySelector('a')?.click(); | |||
| 148 | + | } | |||
| 149 | + | }, | |||
| 150 | + | ||||
| 151 | + | // Diff. | |||
| 152 | + | layout(mode) { | |||
| 153 | + | const el = $('[data-diff]'); | |||
| 154 | + | if (!el) return; | |||
| 155 | + | el.dataset.diff = mode; | |||
| 156 | + | try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ } | |||
| 157 | + | for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true; | |||
| 158 | + | }, | |||
| 159 | + | collapse() { | |||
| 160 | + | const files = $$('[data-file]'); | |||
| 161 | + | const open = !files.some((d) => d.open); | |||
| 162 | + | for (const d of files) d.open = open; | |||
| 163 | + | }, | |||
| 164 | + | nextNote() { | |||
| 165 | + | const notes = $$('#diffs .cmt'); | |||
| 166 | + | if (!notes.length) return; | |||
| 167 | + | const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 168 | + | const n = notes[i < 0 ? 0 : i]; | |||
| 169 | + | const d = n.closest('details'); | |||
| 170 | + | if (d) d.open = true; | |||
| 171 | + | n.scrollIntoView({ block: 'center' }); | |||
| 172 | + | }, | |||
| 173 | + | ||||
| 174 | + | // y: pin the URL to the commit and copy it. | |||
| 175 | + | async permalink() { | |||
| 176 | + | const b = body().dataset; | |||
| 177 | + | if (!b.sha) return flash('no ref to pin on this page'); | |||
| 178 | + | const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |||
| 179 | + | // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>. | |||
| 180 | + | const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname; | |||
| 181 | + | if (path === location.pathname) return flash('this page has no ref to pin'); | |||
| 182 | + | history.replaceState(null, '', path + location.hash); | |||
| 183 | + | try { | |||
| 184 | + | await navigator.clipboard.writeText(location.href); | |||
| 185 | + | flash('permalink ' + b.sha.slice(0, 7) + ' copied'); | |||
| 186 | + | } catch { | |||
| 187 | + | flash('permalink in the address bar (copy refused)'); | |||
| 188 | + | } | |||
| 189 | + | }, | |||
| 190 | + | ||||
| 191 | + | // The [copy] button of a clone line: the label says "Copied" for 1.5 s, | |||
| 192 | + | // and so does the accessible name; the hint badge (a status region) | |||
| 193 | + | // announces it. | |||
| 194 | + | async copy(btn) { | |||
| 195 | + | const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? ''; | |||
| 196 | + | btn.dataset.idleName ||= btn.getAttribute('aria-label') || ''; | |||
| 197 | + | let ok = true; | |||
| 198 | + | try { await navigator.clipboard.writeText(line); } catch { ok = false; } | |||
| 199 | + | btn.textContent = ok ? 'Copied' : 'Copy failed'; | |||
| 200 | + | btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line); | |||
| 201 | + | flash(ok ? 'copied' : 'copy failed', 1500); | |||
| 202 | + | clearTimeout(btn._restore); | |||
| 203 | + | btn._restore = setTimeout(() => { | |||
| 204 | + | btn.textContent = btn.dataset.label; | |||
| 205 | + | btn.setAttribute('aria-label', btn.dataset.idleName); | |||
| 206 | + | }, 1500); | |||
| 207 | + | }, | |||
| 208 | + | }; | |||
| 209 | + | window.ermite = ermite; | |||
| 210 | + | ||||
| 211 | + | // 1. Keys. | |||
| 6 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | 212 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | ||
| 7 | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | 213 | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | ||
| 8 | - | const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', m: 'gm', g: 'top' }; | 214 | + | // "g t" is the key "t": the tabs have one event each. |
| 215 | + | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | |||
| 9 | let pending = 0; | 216 | let pending = 0; | ||
| 10 | - | const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | 217 | + | let pendingTimer = 0; |
| 11 | addEventListener('keydown', (e) => { | 218 | addEventListener('keydown', (e) => { | ||
| 12 | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | 219 | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | ||
| 13 | // ctrl k / cmd k opens the palette; in a field only cmd k, so that the | 220 | // ctrl k / cmd k opens the palette; in a field only cmd k, so that the | ||
| 14 | // field keeps ctrl k (kill to end of line on macOS). | 221 | // field keeps ctrl k (kill to end of line on macOS). | ||
| 15 | - | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return fire('cmd'); } | 222 | + | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); } |
| 16 | if (e.ctrlKey || e.metaKey || e.altKey) return; | 223 | if (e.ctrlKey || e.metaKey || e.altKey) return; | ||
| 17 | - | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); } | 224 | + | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); } |
| 18 | if (typing) return; | 225 | if (typing) return; | ||
| 19 | // An open dialog takes only the keys that move between dialogs: the page | 226 | // An open dialog takes only the keys that move between dialogs: the page | ||
| 20 | // behind it does not react (scripted clicks would go through inert). | 227 | // behind it does not react (scripted clicks would go through inert). | ||
| 21 | - | if (document.querySelector('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | 228 | + | if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } |
| 22 | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | 229 | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | ||
| 23 | if (pending) { | 230 | if (pending) { | ||
| 24 | const name = goto[e.key]; | 231 | const name = goto[e.key]; | ||
| 25 | pending = 0; | 232 | pending = 0; | ||
| 26 | - | if (name) { e.preventDefault(); fire(name); } | 233 | + | if (name) { e.preventDefault(); ermite.fire(name); } |
| 27 | return; | 234 | return; | ||
| 28 | } | 235 | } | ||
| 29 | - | if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; } | 236 | + | if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; } |
| 30 | const name = single[e.key]; | 237 | const name = single[e.key]; | ||
| 31 | if (!name) return; | 238 | if (!name) return; | ||
| 32 | - | if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault(); | 239 | + | if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault(); |
| 33 | - | fire(name); | 240 | + | ermite.fire(name); |
| 241 | + | }); | |||
| 242 | + | ||||
| 243 | + | // 2. A [data-fire] element does what the key of that name does (the header | |||
| 244 | + | // buttons, the diff layout links). The link to the refs page works without | |||
| 245 | + | // scripts and is upgraded here to open the dialog; a click with a modifier | |||
| 246 | + | // keeps its meaning (new tab). | |||
| 247 | + | addEventListener('click', (e) => { | |||
| 248 | + | const el = e.target.closest?.('[data-fire]'); | |||
| 249 | + | if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | |||
| 250 | + | e.preventDefault(); | |||
| 251 | + | ermite.fire(el.dataset.fire); | |||
| 34 | }); | 252 | }); | ||
| 253 | + | for (const a of $$('a[data-fire="refs"]')) { | |||
| 254 | + | a.setAttribute('aria-haspopup', 'dialog'); | |||
| 255 | + | a.setAttribute('aria-controls', 'panel-refs'); | |||
| 256 | + | } | |||
| 257 | + | ||||
| 35 | // In the app shell the page does not scroll, <main> does: give it focus so arrows, | 258 | // In the app shell the page does not scroll, <main> does: give it focus so arrows, | ||
| 36 | // PageUp/PageDown and space scroll the content without a click first. | 259 | // PageUp/PageDown and space scroll the content without a click first. | ||
| 37 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | 260 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | ||
| 261 | + | ||||
| 262 | + | // 4. The table of contents of a diff marks the file in view: the last file | |||
| 263 | + | // in the top third of the window, else the last file whose top is above that | |||
| 264 | + | // line (the first file when none is). One observer tracks the files in the | |||
| 265 | + | // band; it reports only when a file enters or leaves it, so a scroll costs | |||
| 266 | + | // nothing in between, and a jump that skips the band is caught by the scan. | |||
| 267 | + | const files = $$('.dfile'); | |||
| 268 | + | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | |||
| 269 | + | if (files.length && links.size && 'IntersectionObserver' in window) { | |||
| 270 | + | const inBand = new Set(); | |||
| 271 | + | let current = null; | |||
| 272 | + | const mark = () => { | |||
| 273 | + | const f = files.findLast((x) => inBand.has(x)) | |||
| 274 | + | ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3) | |||
| 275 | + | ?? files[0]; | |||
| 276 | + | if (f === current) return; | |||
| 277 | + | links.get(current?.id)?.removeAttribute('aria-current'); | |||
| 278 | + | links.get(f.id)?.setAttribute('aria-current', 'true'); | |||
| 279 | + | current = f; | |||
| 280 | + | }; | |||
| 281 | + | const io = new IntersectionObserver((entries) => { | |||
| 282 | + | for (const e of entries) { | |||
| 283 | + | if (e.isIntersecting) inBand.add(e.target); | |||
| 284 | + | else inBand.delete(e.target); | |||
| 285 | + | } | |||
| 286 | + | mark(); | |||
| 287 | + | }, { rootMargin: '0px 0px -67% 0px' }); | |||
| 288 | + | for (const f of files) io.observe(f); | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // 5. A failure of a handler or of a request (htmx logs it to the console | |||
| 292 | + | // already) is shown, so that a key that does nothing is not a mystery. | |||
| 293 | + | addEventListener('htmx:error', (e) => { | |||
| 294 | + | if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused") | |||
| 295 | + | const err = e.detail?.error; | |||
| 296 | + | flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000); | |||
| 297 | + | }); | |||
| 38 | })(); | 298 | })(); | ||
| 39 | 299 | ||||
| 40 | // ermiteLive applies the fragments of the event stream (/-/events, see | 300 | // ermiteLive applies the fragments of the event stream (/-/events, see | ||
| 41 | // internal/adapters/web/events.go). A fragment is a top-level element with an | 301 | // internal/adapters/web/events.go). A fragment is a top-level element with an | ||
| 42 | // id: it replaces the element of the page that has the same id. A fragment | 302 | // id: it replaces the element of the page that has the same id. A fragment | ||
| 43 | // marked data-gone removes it; a row of a repository the home page does not | 303 | // marked data-gone removes it; a row of a repository the home page does not | ||
| 44 | - | // list yet is added at the top of the list. A gap, which says the page may | 304 | + | // list yet is added at the top of the list. A slot (data-slot: the place of a |
| 45 | - | // have missed events or that what it shows changed, reloads the page. | 305 | + | // banner, a persistent status region) keeps its element and only gets the |
| 306 | + | // children of the fragment, so that screen readers announce the change. A gap, | |||
| 307 | + | // which says the page may have missed events or that what it shows changed, | |||
| 308 | + | // reloads the page. A stream the server refuses on reconnection (the | |||
| 309 | + | // repository is gone, the session ended) makes the page reload. | |||
| 46 | window.ermiteLive = (() => { | 310 | window.ermiteLive = (() => { | ||
| 47 | let reloading = false; | 311 | let reloading = false; | ||
| 312 | + | let stopped = false; | |||
| 48 | return { | 313 | return { | ||
| 49 | swap(html) { | 314 | swap(html) { | ||
| 50 | const t = document.createElement('template'); | 315 | const t = document.createElement('template'); | ||
| 51 | t.innerHTML = html; | 316 | t.innerHTML = html; | ||
| 52 | - | for (const el of t.content.children) { | 317 | + | const cursor = document.querySelector('[data-rows] > [data-cursor]'); |
| 318 | + | const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href'); | |||
| 319 | + | for (const el of [...t.content.children]) { | |||
| 53 | const old = el.id && document.getElementById(el.id); | 320 | const old = el.id && document.getElementById(el.id); | ||
| 54 | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | 321 | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | ||
| 55 | if (!old) { | 322 | if (!old) { | ||
| @@ -57,16 +324,42 @@ window.ermiteLive = (() => { | |||||
| 57 | if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); } | 324 | if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); } | ||
| 58 | continue; | 325 | continue; | ||
| 59 | } | 326 | } | ||
| 327 | + | if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; } | |||
| 60 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | 328 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | ||
| 61 | - | if (old.hidden && old.tagName === 'TR') el.hidden = true; | |||
| 62 | old.replaceWith(el); | 329 | old.replaceWith(el); | ||
| 63 | window.htmx?.process(el); | 330 | window.htmx?.process(el); | ||
| 64 | } | 331 | } | ||
| 332 | + | // The rows may have changed under an active filter: apply it again and | |||
| 333 | + | // keep the cursor on its row while that row is still shown. | |||
| 334 | + | for (const input of document.querySelectorAll('input[data-filter]')) { | |||
| 335 | + | if (input.value) window.ermite?.hide(input); | |||
| 336 | + | } | |||
| 337 | + | if (!cursorKey) return; | |||
| 338 | + | const rows = [...document.querySelectorAll('[data-rows] > *')]; | |||
| 339 | + | const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey)); | |||
| 340 | + | if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back); | |||
| 65 | }, | 341 | }, | ||
| 66 | reload() { | 342 | reload() { | ||
| 67 | if (reloading) return; | 343 | if (reloading) return; | ||
| 68 | reloading = true; | 344 | reloading = true; | ||
| 69 | setTimeout(() => location.reload(), 300); | 345 | setTimeout(() => location.reload(), 300); | ||
| 70 | }, | 346 | }, | ||
| 347 | + | // error handles htmx:sse:error (detail.status is the status of a refused | |||
| 348 | + | // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by | |||
| 349 | + | // itself, and the fetched page says what is the matter (or has no stream). | |||
| 350 | + | // A page that reloaded for this a moment ago and still gets refused would | |||
| 351 | + | // loop: it stops following the stream instead (see gate). | |||
| 352 | + | error(status) { | |||
| 353 | + | if (status !== 404 && status !== 401) return; | |||
| 354 | + | let last = 0; | |||
| 355 | + | try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ } | |||
| 356 | + | if (Date.now() - last < 15000) { stopped = true; return; } | |||
| 357 | + | try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ } | |||
| 358 | + | this.reload(); | |||
| 359 | + | }, | |||
| 360 | + | // gate is htmx:sse:before:connection: once stopped, no more attempts. | |||
| 361 | + | gate(event) { | |||
| 362 | + | if (stopped) event.preventDefault(); | |||
| 363 | + | }, | |||
| 71 | }; | 364 | }; | ||
| 72 | })(); | 365 | })(); | ||
M internal/adapters/web/static/rules.json +9 -0
| @@ -7,6 +7,15 @@ | |||||
| 7 | { "href_matches": "/*" }, | 7 | { "href_matches": "/*" }, | ||
| 8 | { "not": { "href_matches": "/-/*" } }, | 8 | { "not": { "href_matches": "/-/*" } }, | ||
| 9 | { "not": { "href_matches": "/*/raw/*" } }, | 9 | { "not": { "href_matches": "/*/raw/*" } }, | ||
| 10 | + | { "not": { "href_matches": "/*/tree" } }, | |||
| 11 | + | { "not": { "href_matches": "/*/tree/*" } }, | |||
| 12 | + | { "not": { "href_matches": "/*/blob/*" } }, | |||
| 13 | + | { "not": { "href_matches": "/*/commit/*" } }, | |||
| 14 | + | { "not": { "href_matches": "/*/commits" } }, | |||
| 15 | + | { "not": { "href_matches": "/*/commits/*" } }, | |||
| 16 | + | { "not": { "href_matches": "/*/compare/*" } }, | |||
| 17 | + | { "not": { "href_matches": "/*/refs" } }, | |||
| 18 | + | { "not": { "href_matches": "/*/merge-requests/*/since" } }, | |||
| 10 | { "not": { "selector_matches": "[rel~=nofollow]" } } | 19 | { "not": { "selector_matches": "[rel~=nofollow]" } } | ||
| 11 | ] | 20 | ] | ||
| 12 | }, | 21 | }, | ||
M internal/adapters/web/summary.go +53 -52
| @@ -7,15 +7,16 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "html" | 8 | "html" | ||
| 9 | "html/template" | 9 | "html/template" | ||
| 10 | + | "io" | |||
| 10 | "net/http" | 11 | "net/http" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | 14 | ||||
| 14 | - | "connectrpc.com/connect" | |||
| 15 | "github.com/a-h/templ" | 15 | "github.com/a-h/templ" | ||
| 16 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 17 | 17 | ||||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | 18 | + | "bnema.dev/ermite/internal/domain" |
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| 21 | // maxReadmeBytes is the largest README that is rendered in the page; a | 22 | // maxReadmeBytes is the largest README that is rendered in the page; a | ||
| @@ -40,7 +41,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 40 | l, st := s.emptyState(ctx, d) | 41 | l, st := s.emptyState(ctx, d) | ||
| 41 | return State(l, st), http.StatusOK, nil | 42 | return State(l, st), http.StatusOK, nil | ||
| 42 | } | 43 | } | ||
| 43 | - | ref := d.head.GetShortName() | 44 | + | ref := d.head.ShortName |
| 44 | now := s.cfg.Now() | 45 | now := s.cfg.Now() | ||
| 45 | 46 | ||||
| 46 | p := SummaryPage{ | 47 | p := SummaryPage{ | ||
| @@ -48,16 +49,15 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 48 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | 49 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 51 | - | commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 52 | + | commits, err := s.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "") |
| 52 | - | Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits, | |||
| 53 | - | })) | |||
| 54 | if err != nil { | 53 | if err != nil { | ||
| 55 | return nil, 0, err | 54 | return nil, 0, err | ||
| 56 | } | 55 | } | ||
| 57 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | 56 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | ||
| 58 | - | for i, c := range commits.Msg.GetCommits() { | 57 | + | for i, c := range commits.Commits { |
| 58 | + | id := c.ID.String() | |||
| 59 | row := commitRow(d.name(), c, now) | 59 | row := commitRow(d.name(), c, now) | ||
| 60 | - | row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0 | 60 | + | row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0 |
| 61 | p.Recent = append(p.Recent, row) | 61 | p.Recent = append(p.Recent, row) | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| @@ -74,7 +74,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 74 | l.Title = d.name() + " · " + s.instance | 74 | l.Title = d.name() + " · " + s.instance | ||
| 75 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | 75 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | ||
| 76 | l.HasRail = true | 76 | l.HasRail = true | ||
| 77 | - | l.Lede = d.repo.GetDescription() | 77 | + | l.Lede = d.repo.Description |
| 78 | return Summary(l, p), http.StatusOK, nil | 78 | return Summary(l, p), http.StatusOK, nil | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| @@ -90,7 +90,7 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag { | |||||
| 90 | } | 90 | } | ||
| 91 | lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")} | 91 | lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")} | ||
| 92 | if n <= maxTagDates { | 92 | if n <= maxTagDates { | ||
| 93 | - | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" { | 93 | + | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.repo.Name, d.tags)); name != "" { |
| 94 | lt.Name, lt.Href = name, repoHref(d.name(), "tree", name) | 94 | lt.Name, lt.Href = name, repoHref(d.name(), "tree", name) | ||
| 95 | lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt | 95 | lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt | ||
| 96 | } | 96 | } | ||
| @@ -102,33 +102,33 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag { | |||||
| 102 | func (s *Server) facts(d *repoData, now time.Time) []Fact { | 102 | func (s *Server) facts(d *repoData, now time.Time) []Fact { | ||
| 103 | fs := []Fact{ | 103 | fs := []Fact{ | ||
| 104 | lastPushFact(d.repo, now), | 104 | lastPushFact(d.repo, now), | ||
| 105 | - | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | 105 | + | {Key: "branch", Val: d.head.ShortName + " · " + plural(len(d.branches), "branch", "branches")}, |
| 106 | } | 106 | } | ||
| 107 | - | if c := d.repo.GetCreatedAt(); c.IsValid() { | 107 | + | if c := d.repo.CreatedAt; !c.IsZero() { |
| 108 | - | fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())}) | 108 | + | fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)}) |
| 109 | } | 109 | } | ||
| 110 | return fs | 110 | return fs | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | // lastPushFact is the "last push" fact of a repository; the live updates | 113 | // lastPushFact is the "last push" fact of a repository; the live updates | ||
| 114 | // render it again. | 114 | // render it again. | ||
| 115 | - | func lastPushFact(r *pb.Repo, now time.Time) Fact { | 115 | + | func lastPushFact(r ports.RepoView, now time.Time) Fact { |
| 116 | - | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()} | 116 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()} |
| 117 | - | if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | 117 | + | if t := r.PushedAt; !t.IsZero() { |
| 118 | - | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | 118 | + | last.Val = ago(now, t) + " · " + absolute(t) |
| 119 | - | last.Time = rfc3339(t.AsTime()) | 119 | + | last.Time = rfc3339(t) |
| 120 | } | 120 | } | ||
| 121 | return last | 121 | return last | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | // latestTag is the tag whose commit is the newest. | 124 | // latestTag is the tag whose commit is the newest. | ||
| 125 | - | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | 125 | + | func latestTag(tags []domain.Ref, commits map[string]refCommit) (string, refCommit) { |
| 126 | var name string | 126 | var name string | ||
| 127 | var best refCommit | 127 | var best refCommit | ||
| 128 | for _, t := range tags { | 128 | for _, t := range tags { | ||
| 129 | - | c, ok := commits[t.GetCommitId()] | 129 | + | c, ok := commits[t.CommitID.String()] |
| 130 | if ok && c.short != "" && (name == "" || c.at > best.at) { | 130 | if ok && c.short != "" && (name == "" || c.at > best.at) { | ||
| 131 | - | name, best = t.GetShortName(), c | 131 | + | name, best = t.ShortName, c |
| 132 | } | 132 | } | ||
| 133 | } | 133 | } | ||
| 134 | return name, best | 134 | return name, best | ||
| @@ -142,27 +142,31 @@ type renderedReadme struct { | |||||
| 142 | 142 | ||||
| 143 | // readme finds the README at the root of the tree of the head commit, reads | 143 | // readme finds the README at the root of the tree of the head commit, reads | ||
| 144 | // it and renders it. It is nil when the repository has none. Rendering | 144 | // it and renders it. It is nil when the repository has none. Rendering | ||
| 145 | - | // problems degrade to plain text; only API failures are errors. | 145 | + | // problems degrade to plain text; only use case failures are errors. |
| 146 | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | 146 | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | ||
| 147 | - | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()})) | 147 | + | rev := d.head.CommitID.String() |
| 148 | + | tree, err := s.getTree(ctx, d.repo.Name, rev, "", false) | |||
| 148 | if err != nil { | 149 | if err != nil { | ||
| 149 | return nil, err | 150 | return nil, err | ||
| 150 | } | 151 | } | ||
| 151 | - | return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries()) | 152 | + | return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries) |
| 152 | } | 153 | } | ||
| 153 | 154 | ||||
| 154 | // readmeIn picks the README among the entries of the directory dir ("" for | 155 | // readmeIn picks the README among the entries of the directory dir ("" for | ||
| 155 | // the root) of the commit rev, reads it and renders it. Links in it are | 156 | // the root) of the commit rev, reads it and renders it. Links in it are | ||
| 156 | // relative to dir, on the ref ref. It is nil when the directory has none. | 157 | // relative to dir, on the ref ref. It is nil when the directory has none. | ||
| 157 | - | func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) { | 158 | + | // |
| 158 | - | var best *pb.TreeEntry | 159 | + | //hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only |
| 160 | + | func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) { | |||
| 161 | + | var best *domain.TreeEntry | |||
| 159 | rank := len(readmeNames) | 162 | rank := len(readmeNames) | ||
| 160 | - | for _, e := range entries { | 163 | + | for i := range entries { |
| 161 | - | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | 164 | + | e := &entries[i] |
| 165 | + | if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable { | |||
| 162 | continue | 166 | continue | ||
| 163 | } | 167 | } | ||
| 164 | for i, n := range readmeNames { | 168 | for i, n := range readmeNames { | ||
| 165 | - | if i < rank && strings.EqualFold(e.GetName(), n) { | 169 | + | if i < rank && strings.EqualFold(e.Name, n) { |
| 166 | best, rank = e, i | 170 | best, rank = e, i | ||
| 167 | } | 171 | } | ||
| 168 | } | 172 | } | ||
| @@ -170,54 +174,51 @@ func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entri | |||||
| 170 | if best == nil { | 174 | if best == nil { | ||
| 171 | return nil, nil | 175 | return nil, nil | ||
| 172 | } | 176 | } | ||
| 173 | - | rd := &Readme{Name: best.GetName()} | 177 | + | rd := &Readme{Name: best.Name} |
| 174 | - | if best.GetSize() > maxReadmeBytes { | 178 | + | if best.Size > maxReadmeBytes { |
| 175 | - | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | 179 | + | rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")." |
| 176 | return &renderedReadme{readme: rd}, nil | 180 | return &renderedReadme{readme: rd}, nil | ||
| 177 | } | 181 | } | ||
| 178 | - | body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes) | 182 | + | body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes) |
| 179 | if err != nil { | 183 | if err != nil { | ||
| 180 | return nil, err | 184 | return nil, err | ||
| 181 | } | 185 | } | ||
| 182 | if binary { | 186 | if binary { | ||
| 183 | - | rd.Note = best.GetName() + " is not text." | 187 | + | rd.Note = best.Name + " is not text." |
| 184 | return &renderedReadme{readme: rd}, nil | 188 | return &renderedReadme{readme: rd}, nil | ||
| 185 | } | 189 | } | ||
| 186 | - | name := strings.ToLower(best.GetName()) | 190 | + | name := strings.ToLower(best.Name) |
| 187 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | 191 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | ||
| 188 | - | h, outline, err := s.md.render(body, repo, ref, dir) | 192 | + | h, outline, err := s.md.render(body, repo.String(), ref, dir) |
| 189 | if err == nil { | 193 | if err == nil { | ||
| 190 | rd.HTML = h | 194 | rd.HTML = h | ||
| 191 | return &renderedReadme{readme: rd, outline: outline}, nil | 195 | return &renderedReadme{readme: rd, outline: outline}, nil | ||
| 192 | } | 196 | } | ||
| 193 | log := zerowrap.FromCtx(ctx) | 197 | log := zerowrap.FromCtx(ctx) | ||
| 194 | - | log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text") | 198 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("render README, showing plain text") |
| 195 | } | 199 | } | ||
| 196 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | 200 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | ||
| 197 | return &renderedReadme{readme: rd}, nil | 201 | return &renderedReadme{readme: rd}, nil | ||
| 198 | } | 202 | } | ||
| 199 | 203 | ||||
| 200 | - | // readBlob reads a file through the GetBlob stream, at most limit bytes. It | 204 | + | // readBlob reads a file, at most limit bytes. It reports a binary file |
| 201 | - | // reports a binary file without its content. | 205 | + | // without its content. |
| 202 | - | func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) { | 206 | + | func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) { |
| 203 | - | stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path})) | 207 | + | info, rc, err := s.openBlobAt(ctx, repo, rev, path) |
| 204 | if err != nil { | 208 | if err != nil { | ||
| 205 | return nil, false, err | 209 | return nil, false, err | ||
| 206 | } | 210 | } | ||
| 207 | - | defer func() { _ = stream.Close() }() | 211 | + | defer func() { _ = rc.Close() }() |
| 208 | - | for stream.Receive() { | 212 | + | if info.Binary { |
| 209 | - | m := stream.Msg() | 213 | + | return nil, true, nil |
| 210 | - | if m.GetInfo().GetBinary() { | |||
| 211 | - | return nil, true, nil | |||
| 212 | - | } | |||
| 213 | - | body = append(body, m.GetChunk()...) | |||
| 214 | - | if int64(len(body)) > limit { | |||
| 215 | - | return body[:limit], false, nil | |||
| 216 | - | } | |||
| 217 | } | 214 | } | ||
| 218 | - | if err := stream.Err(); err != nil { | 215 | + | body, err = io.ReadAll(io.LimitReader(rc, limit+1)) |
| 216 | + | if err != nil { | |||
| 219 | return nil, false, err | 217 | return nil, false, err | ||
| 220 | } | 218 | } | ||
| 219 | + | if int64(len(body)) > limit { | |||
| 220 | + | return body[:limit], false, nil | |||
| 221 | + | } | |||
| 221 | return body, false, nil | 222 | return body, false, nil | ||
| 222 | } | 223 | } | ||
| 223 | 224 | ||||
M internal/adapters/web/text_test.go +1 -0
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | // Invisible characters in names and subjects show as markers. | 11 | // Invisible characters in names and subjects show as markers. | ||
| 12 | func TestVisibleText(t *testing.T) { | 12 | func TestVisibleText(t *testing.T) { | ||
| 13 | + | t.Parallel() | |||
| 13 | assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go")) | 14 | assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go")) | ||
| 14 | assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”")) | 15 | assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”")) | ||
| 15 | assert.Equal(t, "a<U+0000>b<U+200B>", visibleText("a\x00b\u200b")) | 16 | assert.Equal(t, "a<U+0000>b<U+200B>", visibleText("a\x00b\u200b")) | ||
M internal/adapters/web/tree.go +20 -21
| @@ -6,10 +6,9 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | 8 | ||||
| 9 | - | "connectrpc.com/connect" | |||
| 10 | "github.com/a-h/templ" | 9 | "github.com/a-h/templ" | ||
| 11 | 10 | ||||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | 11 | + | "bnema.dev/ermite/internal/domain" |
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| 15 | // TreeRow is one entry of a directory. | 14 | // TreeRow is one entry of a directory. | ||
| @@ -42,6 +41,8 @@ type TreePage struct { | |||||
| 42 | 41 | ||||
| 43 | // tree lists a directory of a ref, with the last commit of each entry, and | 42 | // tree lists a directory of a ref, with the last commit of each entry, and | ||
| 44 | // renders the README of the directory below. | 43 | // renders the README of the directory below. | ||
| 44 | + | // | |||
| 45 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here | |||
| 45 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 46 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 46 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 47 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 47 | if err != nil { | 48 | if err != nil { | ||
| @@ -51,52 +52,50 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 51 | if !ok { | 52 | if !ok { | ||
| 52 | return nil, 0, errNotFound | 53 | return nil, 0, errNotFound | ||
| 53 | } | 54 | } | ||
| 54 | - | res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{ | 55 | + | res, err := s.getTree(ctx, d.repo.Name, t.rev, t.path, true) |
| 55 | - | Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true, | |||
| 56 | - | })) | |||
| 57 | if err != nil { | 56 | if err != nil { | ||
| 58 | return nil, 0, err | 57 | return nil, 0, err | ||
| 59 | } | 58 | } | ||
| 60 | - | entries := res.Msg.GetEntries() | 59 | + | entries := res.Entries |
| 61 | now := s.cfg.Now() | 60 | now := s.cfg.Now() | ||
| 62 | 61 | ||||
| 63 | rowsOnly := r.URL.Query().Has("rows") | 62 | rowsOnly := r.URL.Query().Has("rows") | ||
| 64 | p := TreePage{} | 63 | p := TreePage{} | ||
| 65 | for i, e := range entries { | 64 | for i, e := range entries { | ||
| 66 | - | row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0 && !rowsOnly} | 65 | + | row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly} |
| 67 | - | switch e.GetKind() { | 66 | + | switch e.Kind { |
| 68 | - | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | 67 | + | case domain.EntryDirectory: |
| 69 | row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-" | 68 | row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-" | ||
| 70 | - | row.Href = repoHref(d.name(), "tree", t.name, e.GetPath()) | 69 | + | row.Href = repoHref(d.name(), "tree", t.name, e.Path) |
| 71 | row.Expand = row.Href + "?rows" | 70 | row.Expand = row.Href + "?rows" | ||
| 72 | - | case pb.EntryKind_ENTRY_KIND_SYMLINK: | 71 | + | case domain.EntrySymlink: |
| 73 | row.Glyph = "@" | 72 | row.Glyph = "@" | ||
| 74 | - | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | 73 | + | row.Href = repoHref(d.name(), "blob", t.name, e.Path) |
| 75 | - | case pb.EntryKind_ENTRY_KIND_SUBMODULE: | 74 | + | case domain.EntrySubmodule: |
| 76 | row.Glyph, row.Size = "+", "-" | 75 | row.Glyph, row.Size = "+", "-" | ||
| 77 | default: | 76 | default: | ||
| 78 | - | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | 77 | + | row.Href = repoHref(d.name(), "blob", t.name, e.Path) |
| 79 | } | 78 | } | ||
| 80 | - | if c := e.GetLastCommit(); c != nil { | 79 | + | if c := e.LastCommit; c != nil { |
| 81 | - | last := commitRow(d.name(), c, now) | 80 | + | last := commitRow(d.name(), *c, now) |
| 82 | row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt | 81 | row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt | ||
| 83 | } | 82 | } | ||
| 84 | p.Rows = append(p.Rows, row) | 83 | p.Rows = append(p.Rows, row) | ||
| 85 | } | 84 | } | ||
| 86 | if rowsOnly { | 85 | if rowsOnly { | ||
| 87 | // The rows alone, for a directory opened in place (files.js). | 86 | // The rows alone, for a directory opened in place (files.js). | ||
| 88 | - | if res.Msg.GetTruncated() { | 87 | + | if res.Truncated { |
| 89 | p.More = repoHref(d.name(), "tree", t.name, t.path) | 88 | p.More = repoHref(d.name(), "tree", t.name, t.path) | ||
| 90 | } | 89 | } | ||
| 91 | return TreeRows(p), http.StatusOK, nil | 90 | return TreeRows(p), http.StatusOK, nil | ||
| 92 | } | 91 | } | ||
| 93 | - | if res.Msg.GetTruncated() { | 92 | + | if res.Truncated { |
| 94 | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | 93 | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | ||
| 95 | } | 94 | } | ||
| 96 | - | if res.Msg.GetLastCommitsTruncated() { | 95 | + | if res.LastCommitsOmitted { |
| 97 | p.Notes = append(p.Notes, "the last commit is shown for the first entries only.") | 96 | p.Notes = append(p.Notes, "the last commit is shown for the first entries only.") | ||
| 98 | } | 97 | } | ||
| 99 | - | rd, err := s.readmeIn(ctx, d.name(), res.Msg.GetCommitId(), t.name, t.path, entries) | 98 | + | rd, err := s.readmeIn(ctx, d.repo.Name, res.CommitID.String(), t.name, t.path, entries) |
| 100 | if err != nil { | 99 | if err != nil { | ||
| 101 | return nil, 0, err | 100 | return nil, 0, err | ||
| 102 | } | 101 | } | ||
| @@ -107,7 +106,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 107 | l := s.repoLayout(ctx, d, "tree") | 106 | l := s.repoLayout(ctx, d, "tree") | ||
| 108 | // The finder searches the commit this page lists, whichever way the URL | 107 | // The finder searches the commit this page lists, whichever way the URL | ||
| 109 | // spelled it (an abbreviated id included): the listing knows it. | 108 | // spelled it (an abbreviated id included): the listing knows it. | ||
| 110 | - | t.commit = res.Msg.GetCommitId() | 109 | + | t.commit = res.CommitID.String() |
| 111 | pinRef(&l, t) | 110 | pinRef(&l, t) | ||
| 112 | l.Crumbs = pathCrumbs(d.name(), "tree", t) | 111 | l.Crumbs = pathCrumbs(d.name(), "tree", t) | ||
| 113 | if t.path == "" { | 112 | if t.path == "" { | ||
M internal/adapters/web/view.go +1 -1
| @@ -5,7 +5,7 @@ package web | |||||
| 5 | import "html/template" | 5 | import "html/template" | ||
| 6 | 6 | ||||
| 7 | // View models handed to the templ components. Components only loop and | 7 | // View models handed to the templ components. Components only loop and | ||
| 8 | - | // branch on these; the handlers build them from the API messages. | 8 | + | // branch on these; the handlers build them from the use case results. |
| 9 | 9 | ||||
| 10 | // Crumb is one segment of the path in the header. | 10 | // Crumb is one segment of the path in the header. | ||
| 11 | type Crumb struct { | 11 | type Crumb struct { | ||
M internal/adapters/web/web.go +70 -35
| @@ -1,15 +1,16 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | // Package web is the HTML front of ermite: the pages of the read-only | 3 | // Package web is the HTML front of ermite: the pages of the read-only | ||
| 4 | - | // showcase, rendered with templ from the data of the ermite API. | 4 | + | // showcase, rendered with templ from the data of the use cases. |
| 5 | // | 5 | // | ||
| 6 | - | // The package is a client of the API like any other. It talks to the | 6 | + | // Like every other inbound adapter, the package reads and writes through the |
| 7 | - | // generated ermite-api Connect clients, which cmd binds in process to the | 7 | + | // use case interfaces of package ports (ADR 0024), never through the API. The |
| 8 | - | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | 8 | + | // caller of every use case call is passed explicitly: the principal of the |
| 9 | - | // cases or the domain. Who the visitor is comes with the request context, set | 9 | + | // request, which is anonymous unless the request carries a valid owner session |
| 10 | - | // by the middleware cmd puts in front of the handler: anonymous in P5. The | 10 | + | // (ServeHTTP sets it in the context, principal reads it). The use cases then |
| 11 | - | // API then shows public repositories only; a private repository is | 11 | + | // show public repositories only to an anonymous caller; a private repository |
| 12 | - | // indistinguishable from a missing one, here as everywhere. | 12 | + | // is indistinguishable from a missing one (domain.ErrNotFound), here as |
| 13 | + | // everywhere. | |||
| 13 | // | 14 | // | ||
| 14 | // Adding a page: a handler on Server, a route in routes, a templ page in | 15 | // Adding a page: a handler on Server, a route in routes, a templ page in | ||
| 15 | // pages.templ and its view model in view.go. | 16 | // pages.templ and its view model in view.go. | ||
| @@ -28,11 +29,11 @@ import ( | |||||
| 28 | "strings" | 29 | "strings" | ||
| 29 | "time" | 30 | "time" | ||
| 30 | 31 | ||||
| 31 | - | "connectrpc.com/connect" | |||
| 32 | "github.com/a-h/templ" | 32 | "github.com/a-h/templ" | ||
| 33 | "github.com/bnema/zerowrap" | 33 | "github.com/bnema/zerowrap" | ||
| 34 | 34 | ||||
| 35 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 35 | + | "bnema.dev/ermite/internal/domain" |
| 36 | + | "bnema.dev/ermite/internal/ports" | |||
| 36 | ) | 37 | ) | ||
| 37 | 38 | ||||
| 38 | // Patterns are the ServeMux patterns the handler serves: everything. The | 39 | // Patterns are the ServeMux patterns the handler serves: everything. The | ||
| @@ -41,20 +42,22 @@ import ( | |||||
| 41 | // over this one. | 42 | // over this one. | ||
| 42 | var Patterns = []string{"/"} | 43 | var Patterns = []string{"/"} | ||
| 43 | 44 | ||||
| 44 | - | // requestTimeout bounds the API calls that build one page. | 45 | + | // requestTimeout bounds the use case calls that build one page. |
| 45 | const requestTimeout = 30 * time.Second | 46 | const requestTimeout = 30 * time.Second | ||
| 46 | 47 | ||||
| 47 | // Config configures a Server. | 48 | // Config configures a Server. | ||
| 48 | type Config struct { | 49 | type Config struct { | ||
| 49 | - | // Repos and Git are the API clients the pages are built from. | 50 | + | // Repos and Browse are the use cases the pages are built from. |
| 50 | - | Repos ermiteconnect.RepoServiceClient | 51 | + | Repos ports.RepoService |
| 51 | - | Git ermiteconnect.GitServiceClient | 52 | + | Browse ports.BrowseService |
| 52 | - | // MergeRequests is the client of the merge request pages (ADR 0017). | 53 | + | // MergeRequests serves the merge request pages (ADR 0017). Without it |
| 53 | - | // Without it there are no such pages, nor section for them. | 54 | + | // there are no such pages, nor section for them. Leave it unset rather |
| 54 | - | MergeRequests ermiteconnect.MergeRequestServiceClient | 55 | + | // than assigning a typed nil pointer. |
| 55 | - | // Events is the client of the event stream behind /-/events. Without | 56 | + | MergeRequests ports.MergeRequestService |
| 56 | - | // it the pages are not live and the route answers 404. | 57 | + | // Events is the source of the event stream behind /-/events. Without |
| 57 | - | Events ermiteconnect.EventServiceClient | 58 | + | // it the pages are not live and the route answers 404. Leave it unset |
| 59 | + | // rather than assigning a typed nil pointer. | |||
| 60 | + | Events ports.EventService | |||
| 58 | // Heartbeat is how often an idle event stream gets a comment line; | 61 | // Heartbeat is how often an idle event stream gets a comment line; | ||
| 59 | // DefaultHeartbeat when zero. | 62 | // DefaultHeartbeat when zero. | ||
| 60 | Heartbeat time.Duration | 63 | Heartbeat time.Duration | ||
| @@ -69,10 +72,10 @@ type Config struct { | |||||
| 69 | Now func() time.Time | 72 | Now func() time.Time | ||
| 70 | // Sessions signs the owner in (ADR 0018). Without it there are no login | 73 | // Sessions signs the owner in (ADR 0018). Without it there are no login | ||
| 71 | // routes and every visitor is anonymous. | 74 | // routes and every visitor is anonymous. | ||
| 72 | - | Sessions SessionAuth | 75 | + | Sessions ports.WebSessionService |
| 73 | // Limiter counts the failed logins; required with Sessions. Share it with | 76 | // Limiter counts the failed logins; required with Sessions. Share it with | ||
| 74 | // the API and Git, so that a client has one budget of failures. | 77 | // the API and Git, so that a client has one budget of failures. | ||
| 75 | - | Limiter FailureLimiter | 78 | + | Limiter ports.FailureLimiter |
| 76 | // TrustedProxies are the networks allowed to set X-Forwarded-For, to find | 79 | // TrustedProxies are the networks allowed to set X-Forwarded-For, to find | ||
| 77 | // the address of the client that fails a login. | 80 | // the address of the client that fails a login. | ||
| 78 | TrustedProxies []netip.Prefix | 81 | TrustedProxies []netip.Prefix | ||
| @@ -88,12 +91,15 @@ type Server struct { | |||||
| 88 | // pathCache holds the path lists the file finder fetches. | 91 | // pathCache holds the path lists the file finder fetches. | ||
| 89 | pathCache *pathCache | 92 | pathCache *pathCache | ||
| 90 | mux *http.ServeMux | 93 | mux *http.ServeMux | ||
| 94 | + | // refsFan shares the fragments of the refs page between the watchers of | |||
| 95 | + | // a push; entries are few and short-lived. | |||
| 96 | + | refsFan fanout | |||
| 91 | } | 97 | } | ||
| 92 | 98 | ||||
| 93 | // New builds the web handler. | 99 | // New builds the web handler. | ||
| 94 | func New(cfg Config) (*Server, error) { | 100 | func New(cfg Config) (*Server, error) { | ||
| 95 | - | if cfg.Repos == nil || cfg.Git == nil { | 101 | + | if cfg.Repos == nil || cfg.Browse == nil { |
| 96 | - | return nil, errors.New("web: the repository and Git API clients are required") | 102 | + | return nil, errors.New("web: the repository and browse use cases are required") |
| 97 | } | 103 | } | ||
| 98 | u, err := url.Parse(cfg.PublicURL) | 104 | u, err := url.Parse(cfg.PublicURL) | ||
| 99 | if err != nil || u.Host == "" { | 105 | if err != nil || u.Host == "" { | ||
| @@ -161,6 +167,9 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 161 | 167 | ||||
| 162 | // ServeHTTP implements http.Handler. | 168 | // ServeHTTP implements http.Handler. | ||
| 163 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 169 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 170 | + | // Whatever the context carried, the caller is anonymous until a valid | |||
| 171 | + | // session says otherwise (withSession). | |||
| 172 | + | r = r.WithContext(domain.ContextWithPrincipal(r.Context(), domain.Anonymous())) | |||
| 164 | r = s.withSession(w, r) | 173 | r = s.withSession(w, r) | ||
| 165 | if !s.gate(w, r) { | 174 | if !s.gate(w, r) { | ||
| 166 | return | 175 | return | ||
| @@ -172,7 +181,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 172 | // error, which becomes the 404 or 500 page. | 181 | // error, which becomes the 404 or 500 page. | ||
| 173 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | 182 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | ||
| 174 | 183 | ||||
| 175 | - | // page adapts a handler: it bounds the API calls, renders the component into | 184 | + | // page adapts a handler: it bounds the use case calls, renders the component into |
| 176 | // a buffer (so that a failure never leaves half a page), and maps errors to | 185 | // a buffer (so that a failure never leaves half a page), and maps errors to | ||
| 177 | // the state pages. | 186 | // the state pages. | ||
| 178 | func (s *Server) page(h handler) http.HandlerFunc { | 187 | func (s *Server) page(h handler) http.HandlerFunc { | ||
| @@ -209,21 +218,47 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque | |||||
| 209 | if h.Get("Cache-Control") == "" { | 218 | if h.Get("Cache-Control") == "" { | ||
| 210 | cacheHeaders(ctx, h, "no-cache", sessionCache) | 219 | cacheHeaders(ctx, h, "no-cache", sessionCache) | ||
| 211 | } | 220 | } | ||
| 221 | + | body := buf.Bytes() | |||
| 222 | + | // Whether the body is sent gzipped follows from its size and the request | |||
| 223 | + | // alone, so that the tag is known before the compression is spent. | |||
| 224 | + | gz := len(body) >= minCompress && acceptsGzip(r.Header) && | |||
| 225 | + | !noTransform(h) && h.Get("Content-Encoding") == "" | |||
| 226 | + | addVary(h, "Accept-Encoding") | |||
| 227 | + | readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead | |||
| 228 | + | // A page that may be stored gets a validator: the browser then | |||
| 229 | + | // revalidates with If-None-Match and a page that did not change costs a | |||
| 230 | + | // 304. A no-store page (the owner's) is never revalidated. | |||
| 231 | + | if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) { | |||
| 232 | + | tag := pageETag(body, SignedIn(ctx), gz) | |||
| 233 | + | h.Set("ETag", tag) | |||
| 234 | + | if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) { | |||
| 235 | + | h.Del("Content-Type") | |||
| 236 | + | w.WriteHeader(http.StatusNotModified) | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | if gz { | |||
| 241 | + | if z, err := gzipPage(body); err == nil && len(z) < len(body) { | |||
| 242 | + | body = z | |||
| 243 | + | h.Set("Content-Encoding", "gzip") | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | h.Set("Content-Length", itoa(len(body))) | |||
| 212 | w.WriteHeader(status) | 247 | w.WriteHeader(status) | ||
| 213 | if r.Method != http.MethodHead { | 248 | if r.Method != http.MethodHead { | ||
| 214 | - | _, _ = w.Write(buf.Bytes()) | 249 | + | _, _ = w.Write(body) |
| 215 | } | 250 | } | ||
| 216 | } | 251 | } | ||
| 217 | 252 | ||||
| 218 | - | // fail answers an error of the API: not found (and a name or revision the | 253 | + | // fail answers an error of a use case: not found (and a name or revision the |
| 219 | - | // API refuses, which no page can show either) gives the 404 page, a visitor | 254 | + | // use case refuses, which no page can show either) gives the 404 page, a |
| 220 | - | // that left gives nothing, anything else gives the 500 page with an id to | 255 | + | // visitor that left gives nothing, anything else gives the 500 page with an id |
| 221 | - | // find the cause in the log. | 256 | + | // to find the cause in the log. |
| 222 | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | 257 | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | ||
| 223 | - | switch code := connect.CodeOf(err); { | 258 | + | switch { |
| 224 | - | case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | 259 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): |
| 225 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | 260 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | ||
| 226 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | 261 | + | case errors.Is(err, context.Canceled): |
| 227 | // The client is gone; there is nobody to answer. | 262 | // The client is gone; there is nobody to answer. | ||
| 228 | default: | 263 | default: | ||
| 229 | id := requestID() | 264 | id := requestID() | ||
| @@ -234,8 +269,8 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques | |||||
| 234 | } | 269 | } | ||
| 235 | 270 | ||||
| 236 | // errNotFound is returned by handlers for a resource that does not exist | 271 | // errNotFound is returned by handlers for a resource that does not exist | ||
| 237 | - | // although the API did not say so (a ref or a path that is not there). | 272 | + | // although the use case did not say so (a ref or a path that is not there). |
| 238 | - | var errNotFound = errors.New("not found") | 273 | + | var errNotFound = domain.ErrNotFound |
| 239 | 274 | ||||
| 240 | // requestID is a short random id, shown on the 500 page and logged. | 275 | // requestID is a short random id, shown on the 500 page and logged. | ||
| 241 | func requestID() string { | 276 | func requestID() string { | ||
R cmd/ermite/api_helpers_test.go → internal/app/api_helpers_test.go +3 -3 renamed, 97% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -19,8 +19,8 @@ import ( | |||||
| 19 | 19 | ||||
| 20 | pb "bnema.dev/ermite-api/gen/ermite" | 20 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 21 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 21 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | ||
| 22 | - | "bnema.dev/ermite/internal/app" | |||
| 23 | "bnema.dev/ermite/internal/domain" | 22 | "bnema.dev/ermite/internal/domain" | ||
| 23 | + | "bnema.dev/ermite/internal/ports" | |||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | // clients are the generated Connect clients of one caller. | 26 | // clients are the generated Connect clients of one caller. | ||
| @@ -105,7 +105,7 @@ func codeName(err error) string { | |||||
| 105 | // commit per file to its main branch with git. | 105 | // commit per file to its main branch with git. | ||
| 106 | func (h *harness) seed(name string, vis domain.Visibility, files ...string) { | 106 | func (h *harness) seed(name string, vis domain.Visibility, files ...string) { | ||
| 107 | h.t.Helper() | 107 | h.t.Helper() | ||
| 108 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: vis}) | 108 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: vis}) |
| 109 | require.NoError(h.t, err) | 109 | require.NoError(h.t, err) | ||
| 110 | if len(files) == 0 { | 110 | if len(files) == 0 { | ||
| 111 | return | 111 | return | ||
R cmd/ermite/api_test.go → internal/app/api_test.go +5 -74 renamed, 91% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| @@ -24,9 +24,9 @@ import ( | |||||
| 24 | 24 | ||||
| 25 | pb "bnema.dev/ermite-api/gen/ermite" | 25 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 26 | "bnema.dev/ermite/internal/adapters/rpc" | 26 | "bnema.dev/ermite/internal/adapters/rpc" | ||
| 27 | - | "bnema.dev/ermite/internal/app" | |||
| 28 | "bnema.dev/ermite/internal/clientlimit" | 27 | "bnema.dev/ermite/internal/clientlimit" | ||
| 29 | "bnema.dev/ermite/internal/domain" | 28 | "bnema.dev/ermite/internal/domain" | ||
| 29 | + | "bnema.dev/ermite/internal/ports" | |||
| 30 | ) | 30 | ) | ||
| 31 | 31 | ||||
| 32 | // principals of the access tables. | 32 | // principals of the access tables. | ||
| @@ -690,8 +690,8 @@ func TestGetBlobStreamsInChunks(t *testing.T) { | |||||
| 690 | } | 690 | } | ||
| 691 | } | 691 | } | ||
| 692 | 692 | ||||
| 693 | - | func createParams(h *harness, name string, vis domain.Visibility) app.CreateParams { | 693 | + | func createParams(h *harness, name string, vis domain.Visibility) ports.CreateRepoParams { |
| 694 | - | return app.CreateParams{Name: h.name(name), Visibility: vis} | 694 | + | return ports.CreateRepoParams{Name: h.name(name), Visibility: vis} |
| 695 | } | 695 | } | ||
| 696 | 696 | ||||
| 697 | // TestUpdateRepoDefaultBranch: HEAD moves with git symbolic-ref once the | 697 | // TestUpdateRepoDefaultBranch: HEAD moves with git symbolic-ref once the | ||
| @@ -803,77 +803,8 @@ func TestDeleteRepoMovesToTrash(t *testing.T) { | |||||
| 803 | assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process") | 803 | assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process") | ||
| 804 | } | 804 | } | ||
| 805 | 805 | ||||
| 806 | - | // TestInProcessClient: the handlers answer calls made in the process with a | |||
| 807 | - | // principal in the context, with no HTTP authentication, and refuse to be | |||
| 808 | - | // fooled by an Authorization header. | |||
| 809 | - | func TestInProcessClient(t *testing.T) { | |||
| 810 | - | h := newHarness(t, harnessOpts{}) | |||
| 811 | - | h.seed("pub", domain.VisibilityPublic, "README.md") | |||
| 812 | - | h.seed("priv", domain.VisibilityPrivate, "README.md") | |||
| 813 | - | c := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | |||
| 814 | - | list := func(ctx context.Context) []string { | |||
| 815 | - | resp, err := c.repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{})) | |||
| 816 | - | require.NoError(t, err) | |||
| 817 | - | var out []string | |||
| 818 | - | for _, r := range resp.Msg.GetRepos() { | |||
| 819 | - | out = append(out, r.GetName()) | |||
| 820 | - | } | |||
| 821 | - | return out | |||
| 822 | - | } | |||
| 823 | - | ||||
| 824 | - | assert.Equal(t, []string{"pub"}, list(h.ctx), "no principal in the context is anonymous") | |||
| 825 | - | assert.Equal(t, []string{"pub"}, list(domain.ContextWithPrincipal(h.ctx, domain.Anonymous()))) | |||
| 826 | - | owner := domain.ContextWithPrincipal(h.ctx, domain.Root()) | |||
| 827 | - | assert.Equal(t, []string{"priv", "pub"}, list(owner)) | |||
| 828 | - | ||||
| 829 | - | // Mutations and streams work the same way. | |||
| 830 | - | _, err := c.repo.CreateRepo(owner, connect.NewRequest(&pb.CreateRepoRequest{Name: "web-made"})) | |||
| 831 | - | require.NoError(t, err) | |||
| 832 | - | _, err = c.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "denied"})) | |||
| 833 | - | assert.Equal(t, "unauthenticated", codeName(err)) | |||
| 834 | - | msgs, err := readBlob(owner, c, &pb.GetBlobRequest{Repo: "priv", Path: "README.md"}) | |||
| 835 | - | require.NoError(t, err) | |||
| 836 | - | assert.Equal(t, "content of README.md\n", string(msgs[0].GetChunk())) | |||
| 837 | - | _, err = readBlob(h.ctx, c, &pb.GetBlobRequest{Repo: "priv", Path: "README.md"}) | |||
| 838 | - | assert.Equal(t, "not_found", codeName(err)) | |||
| 839 | - | ||||
| 840 | - | wctx, cancel := context.WithCancel(owner) | |||
| 841 | - | defer cancel() | |||
| 842 | - | st, err := c.event.Watch(wctx, connect.NewRequest(&pb.WatchRequest{Topics: []string{"repo:web-made"}})) | |||
| 843 | - | require.NoError(t, err) | |||
| 844 | - | got := make(chan *pb.Event, 1) | |||
| 845 | - | go func() { | |||
| 846 | - | if st.Receive() { | |||
| 847 | - | got <- st.Msg().GetEvent() | |||
| 848 | - | } | |||
| 849 | - | }() | |||
| 850 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 851 | - | _, err = c.repo.UpdateRepo(owner, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 852 | - | Repo: "web-made", Description: "d", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |||
| 853 | - | })) | |||
| 854 | - | require.NoError(t, err) | |||
| 855 | - | select { | |||
| 856 | - | case ev := <-got: | |||
| 857 | - | assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription()) | |||
| 858 | - | case <-time.After(10 * time.Second): | |||
| 859 | - | t.Fatal("no event through the in-process client") | |||
| 860 | - | } | |||
| 861 | - | ||||
| 862 | - | // An Authorization header does not give a principal in process. | |||
| 863 | - | hc := h.forge.API.InProcessClient() | |||
| 864 | - | req, _ := http.NewRequestWithContext(h.ctx, http.MethodPost, rpc.InProcessURL+"/ermite.RepoService/ListRepos", strings.NewReader("{}")) | |||
| 865 | - | req.Header.Set("Content-Type", "application/json") | |||
| 866 | - | req.Header.Set("Authorization", "Bearer "+adminToken) | |||
| 867 | - | resp, err := hc.Do(req) | |||
| 868 | - | require.NoError(t, err) | |||
| 869 | - | body, _ := io.ReadAll(resp.Body) | |||
| 870 | - | _ = resp.Body.Close() | |||
| 871 | - | assert.NotContains(t, string(body), `"priv"`) | |||
| 872 | - | } | |||
| 873 | - | ||||
| 874 | // A principal in the base context of the HTTP server does not authenticate | 806 | // A principal in the base context of the HTTP server does not authenticate | ||
| 875 | - | // requests: only the in-process transport can mark a context as trusted, and | 807 | + | // requests: requests from the network are judged by their headers. |
| 876 | - | // requests from the network are judged by their headers. | |||
| 877 | func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) { | 808 | func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) { | ||
| 878 | h := newHarness(t, harnessOpts{}) | 809 | h := newHarness(t, harnessOpts{}) | ||
| 879 | h.seed("pub", domain.VisibilityPublic, "README.md") | 810 | h.seed("pub", domain.VisibilityPublic, "README.md") | ||
A internal/app/app.go +51 -0
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package app is the composition root and the command line of ermite: it | |||
| 4 | + | // parses the CLI, reads the environment, builds the adapters and wires them to | |||
| 5 | + | // the use cases. cmd/ermite is only its entry point. | |||
| 6 | + | package app | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "runtime/debug" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Run executes the ermite command line with args, the command-line arguments | |||
| 13 | + | // without the program name. It returns the error of the command, which the | |||
| 14 | + | // caller prints once. | |||
| 15 | + | func Run(args []string) error { | |||
| 16 | + | root := newRootCmd() | |||
| 17 | + | if args == nil { | |||
| 18 | + | args = []string{} | |||
| 19 | + | } | |||
| 20 | + | root.SetArgs(args) | |||
| 21 | + | return root.Execute() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // ExitCode is the process exit status for the error Run returned. | |||
| 25 | + | func ExitCode(err error) int { | |||
| 26 | + | if err == nil { | |||
| 27 | + | return 0 | |||
| 28 | + | } | |||
| 29 | + | return 1 | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // stamped is the release version set at link time by the image build | |||
| 33 | + | // (-ldflags "-X bnema.dev/ermite/internal/app.stamped=v0.1.0"). Empty for a | |||
| 34 | + | // plain go build, which then reports the module version recorded by the Go | |||
| 35 | + | // toolchain. | |||
| 36 | + | var stamped string | |||
| 37 | + | ||||
| 38 | + | // buildVersion is the version stamped into the binary: the link-time version | |||
| 39 | + | // when set, else the module version of the build. | |||
| 40 | + | func buildVersion() string { | |||
| 41 | + | if stamped != "" { | |||
| 42 | + | return stamped | |||
| 43 | + | } | |||
| 44 | + | info, ok := debug.ReadBuildInfo() | |||
| 45 | + | if !ok || info.Main.Version == "" { | |||
| 46 | + | return "(devel)" | |||
| 47 | + | } | |||
| 48 | + | return info.Main.Version | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func version() string { return "ermite " + buildVersion() } | |||
R cmd/ermite/cli.go → internal/app/cli.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
R cmd/ermite/cli_binary_test.go → internal/app/cli_binary_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
R cmd/ermite/cli_e2e_test.go → internal/app/cli_e2e_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bufio" | 6 | "bufio" | ||
R cmd/ermite/cli_fake_test.go → internal/app/cli_fake_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
R cmd/ermite/cli_mr.go → internal/app/cli_mr.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
R cmd/ermite/cli_mr_e2e_test.go → internal/app/cli_mr_e2e_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os" | 6 | "os" | ||
R cmd/ermite/cli_mr_test.go → internal/app/cli_mr_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "strings" | 6 | "strings" | ||
R cmd/ermite/cli_repo.go → internal/app/cli_repo.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bufio" | 6 | "bufio" | ||
R cmd/ermite/cli_repo_test.go → internal/app/cli_repo_test.go +50 -36 renamed, 88% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
| @@ -34,41 +34,55 @@ func envFor(url string) map[string]string { | |||||
| 34 | return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"} | 34 | return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"} | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | func TestRepoListTable(t *testing.T) { | 37 | + | func TestRepoList(t *testing.T) { |
| 38 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | 38 | + | both := []*ermite.Repo{repoAlpha, repoBeta} |
| 39 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | 39 | + | tests := []struct { |
| 40 | - | }} | 40 | + | name string |
| 41 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t) | 41 | + | repos []*ermite.Repo |
| 42 | - | assert.Equal(t, ""+ | 42 | + | args []string |
| 43 | - | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+ | 43 | + | wantStdout string |
| 44 | - | "alpha public main 2026-03-04 05:06 The first one\n"+ | 44 | + | wantJSON bool // compare stdout as JSON rather than byte for byte |
| 45 | - | "beta private trunk never \n", res.stdout) | 45 | + | wantStderr string |
| 46 | - | assert.Empty(t, res.stderr) | 46 | + | }{ |
| 47 | - | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | 47 | + | { |
| 48 | - | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | 48 | + | name: "table", repos: both, args: []string{"repo", "list"}, |
| 49 | - | } | 49 | + | wantStdout: "" + |
| 50 | - | 50 | + | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" + | |
| 51 | - | func TestRepoListJSON(t *testing.T) { | 51 | + | "alpha public main 2026-03-04 05:06 The first one\n" + |
| 52 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | 52 | + | "beta private trunk never \n", |
| 53 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | 53 | + | }, |
| 54 | - | }} | 54 | + | { |
| 55 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t) | 55 | + | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, |
| 56 | - | assert.JSONEq(t, `[ | 56 | + | wantStdout: `[ |
| 57 | - | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | 57 | + | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, |
| 58 | - | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | 58 | + | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} |
| 59 | - | ]`, res.stdout) | 59 | + | ]`, |
| 60 | - | } | 60 | + | }, |
| 61 | - | 61 | + | { | |
| 62 | - | func TestRepoListEmpty(t *testing.T) { | 62 | + | name: "empty, table", args: []string{"repo", "list"}, |
| 63 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | 63 | + | wantStderr: "ermite: no repositories\n", |
| 64 | - | return &ermite.ListReposResponse{}, nil | 64 | + | }, |
| 65 | - | }} | 65 | + | { |
| 66 | - | url := f.serve(t) | 66 | + | name: "empty, json is [] not null", args: []string{"repo", "list", "--json"}, |
| 67 | - | res := runCLI(t, envFor(url), "", "repo", "list").ok(t) | 67 | + | wantStdout: "[]\n", |
| 68 | - | assert.Empty(t, res.stdout) | 68 | + | }, |
| 69 | - | assert.Equal(t, "ermite: no repositories\n", res.stderr) | 69 | + | } |
| 70 | - | res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t) | 70 | + | for _, tt := range tests { |
| 71 | - | assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null") | 71 | + | t.Run(tt.name, func(t *testing.T) { |
| 72 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |||
| 73 | + | return &ermite.ListReposResponse{Repos: tt.repos}, nil | |||
| 74 | + | }} | |||
| 75 | + | res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t) | |||
| 76 | + | if tt.wantJSON { | |||
| 77 | + | assert.JSONEq(t, tt.wantStdout, res.stdout) | |||
| 78 | + | } else { | |||
| 79 | + | assert.Equal(t, tt.wantStdout, res.stdout) | |||
| 80 | + | } | |||
| 81 | + | assert.Equal(t, tt.wantStderr, res.stderr) | |||
| 82 | + | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | |||
| 83 | + | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 72 | } | 86 | } | ||
| 73 | 87 | ||||
| 74 | func TestRepoListFollowsPages(t *testing.T) { | 88 | func TestRepoListFollowsPages(t *testing.T) { | ||
R cmd/ermite/cli_signing.go → internal/app/cli_signing.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
R cmd/ermite/cli_token.go → internal/app/cli_token.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
R cmd/ermite/cli_token_test.go → internal/app/cli_token_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "testing" | 6 | "testing" | ||
R cmd/ermite/cli_version.go → internal/app/cli_version.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
R cmd/ermite/cli_version_test.go → internal/app/cli_version_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
R cmd/ermite/cli_watch.go → internal/app/cli_watch.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
R cmd/ermite/cli_watch_test.go → internal/app/cli_watch_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bufio" | 6 | "bufio" | ||
R cmd/ermite/cli_web.go → internal/app/cli_web.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
R cmd/ermite/clock.go → internal/app/clock.go +1 -1 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import "time" | 5 | import "time" | ||
| 6 | 6 | ||||
R cmd/ermite/datadir.go → internal/app/datadir.go +1 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
R cmd/ermite/e2e_helpers_test.go → internal/app/e2e_helpers_test.go +4 -4 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| @@ -18,9 +18,9 @@ import ( | |||||
| 18 | "github.com/bnema/zerowrap" | 18 | "github.com/bnema/zerowrap" | ||
| 19 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 20 | 20 | ||||
| 21 | - | "bnema.dev/ermite/internal/app" | |||
| 22 | "bnema.dev/ermite/internal/config" | 21 | "bnema.dev/ermite/internal/config" | ||
| 23 | "bnema.dev/ermite/internal/domain" | 22 | "bnema.dev/ermite/internal/domain" | ||
| 23 | + | "bnema.dev/ermite/internal/ports" | |||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | // harness is a forge wired like `ermite serve`, behind an httptest server, | 26 | // harness is a forge wired like `ermite serve`, behind an httptest server, | ||
| @@ -69,7 +69,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness { | |||||
| 69 | if o.trustedProxies != "" { | 69 | if o.trustedProxies != "" { | ||
| 70 | env[config.EnvTrustedProxies] = o.trustedProxies | 70 | env[config.EnvTrustedProxies] = o.trustedProxies | ||
| 71 | } | 71 | } | ||
| 72 | - | // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite | 72 | + | // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./internal/app |
| 73 | if r := os.Getenv(config.EnvGitReader); r != "" { | 73 | if r := os.Getenv(config.EnvGitReader); r != "" { | ||
| 74 | env[config.EnvGitReader] = r | 74 | env[config.EnvGitReader] = r | ||
| 75 | } | 75 | } | ||
| @@ -186,7 +186,7 @@ func (h *harness) status(method, path string, hdr http.Header) int { | |||||
| 186 | // token creates a stored token through the use case, as root. | 186 | // token creates a stored token through the use case, as root. | ||
| 187 | func (h *harness) token(scopes []domain.Scope, repos ...string) string { | 187 | func (h *harness) token(scopes []domain.Scope, repos ...string) string { | ||
| 188 | h.t.Helper() | 188 | h.t.Helper() | ||
| 189 | - | p := app.CreateTokenParams{Label: "e2e", Scopes: scopes} | 189 | + | p := ports.CreateTokenParams{Label: "e2e", Scopes: scopes} |
| 190 | for _, r := range repos { | 190 | for _, r := range repos { | ||
| 191 | p.Repos = append(p.Repos, h.name(r)) | 191 | p.Repos = append(p.Repos, h.name(r)) | ||
| 192 | } | 192 | } | ||
R cmd/ermite/e2e_test.go → internal/app/e2e_test.go +9 -9 renamed, 97% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -20,8 +20,8 @@ import ( | |||||
| 20 | "github.com/stretchr/testify/assert" | 20 | "github.com/stretchr/testify/assert" | ||
| 21 | "github.com/stretchr/testify/require" | 21 | "github.com/stretchr/testify/require" | ||
| 22 | 22 | ||||
| 23 | - | "bnema.dev/ermite/internal/app" | |||
| 24 | "bnema.dev/ermite/internal/domain" | 23 | "bnema.dev/ermite/internal/domain" | ||
| 24 | + | "bnema.dev/ermite/internal/ports" | |||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | func scopes(s ...domain.Scope) []domain.Scope { return s } | 27 | func scopes(s ...domain.Scope) []domain.Scope { return s } | ||
| @@ -29,7 +29,7 @@ func scopes(s ...domain.Scope) []domain.Scope { return s } | |||||
| 29 | // publicRepo creates a public repository with one commit pushed by root. | 29 | // publicRepo creates a public repository with one commit pushed by root. | ||
| 30 | func (h *harness) publicRepo(name string, files ...string) { | 30 | func (h *harness) publicRepo(name string, files ...string) { | ||
| 31 | h.t.Helper() | 31 | h.t.Helper() | ||
| 32 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | 32 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) |
| 33 | require.NoError(h.t, err) | 33 | require.NoError(h.t, err) | ||
| 34 | wt := h.workTree("main", files...) | 34 | wt := h.workTree("main", files...) | ||
| 35 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main") | 35 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main") | ||
| @@ -189,7 +189,7 @@ func TestAC2PushAuthorization(t *testing.T) { | |||||
| 189 | assert.Contains(t, out, "Authentication failed") | 189 | assert.Contains(t, out, "Authentication failed") | ||
| 190 | 190 | ||||
| 191 | // Revoked token. | 191 | // Revoked token. | ||
| 192 | - | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{Label: "short", Scopes: scopes(domain.ScopeRepoWrite)}) | 192 | + | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{Label: "short", Scopes: scopes(domain.ScopeRepoWrite)}) |
| 193 | require.NoError(t, err) | 193 | require.NoError(t, err) | ||
| 194 | next("before-revoke") | 194 | next("before-revoke") | ||
| 195 | out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main") | 195 | out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main") | ||
| @@ -570,20 +570,20 @@ func TestAC8TokenCreationThroughTheForge(t *testing.T) { | |||||
| 570 | h := newHarness(t, harnessOpts{}) | 570 | h := newHarness(t, harnessOpts{}) | ||
| 571 | ctx := context.Background() | 571 | ctx := context.Background() | ||
| 572 | 572 | ||||
| 573 | - | admin, err := h.forge.Tokens.Create(ctx, domain.Root(), app.CreateTokenParams{ | 573 | + | admin, err := h.forge.Tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ |
| 574 | Label: "limited admin", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}, | 574 | Label: "limited admin", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}, | ||
| 575 | }) | 575 | }) | ||
| 576 | require.NoError(t, err) | 576 | require.NoError(t, err) | ||
| 577 | p, err := h.forge.Auth.Authenticate(ctx, admin.Secret) | 577 | p, err := h.forge.Auth.Authenticate(ctx, admin.Secret) | ||
| 578 | require.NoError(t, err) | 578 | require.NoError(t, err) | ||
| 579 | 579 | ||||
| 580 | - | _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoAdmin), Repos: []domain.RepoName{h.name("a")}}) | 580 | + | _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoAdmin), Repos: []domain.RepoName{h.name("a")}}) |
| 581 | assert.ErrorIs(t, err, domain.ErrForbidden, "scope escalation") | 581 | assert.ErrorIs(t, err, domain.ErrForbidden, "scope escalation") | ||
| 582 | - | _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)}) | 582 | + | _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)}) |
| 583 | assert.ErrorIs(t, err, domain.ErrForbidden, "repository list escalation") | 583 | assert.ErrorIs(t, err, domain.ErrForbidden, "repository list escalation") | ||
| 584 | - | _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("b")}}) | 584 | + | _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("b")}}) |
| 585 | assert.ErrorIs(t, err, domain.ErrForbidden, "another repository") | 585 | assert.ErrorIs(t, err, domain.ErrForbidden, "another repository") | ||
| 586 | - | child, err := h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "child", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}}) | 586 | + | child, err := h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "child", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}}) |
| 587 | require.NoError(t, err) | 587 | require.NoError(t, err) | ||
| 588 | 588 | ||||
| 589 | // The child works on its repository, and revoking the index entry kills it at once. | 589 | // The child works on its repository, and revoking the index entry kills it at once. | ||
M internal/app/events_test.go +338 -700
| @@ -1,767 +1,405 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package app_test | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | - | "sync" | 9 | + | "net/http" |
| 10 | + | "os/exec" | |||
| 11 | + | "syscall" | |||
| 10 | "testing" | 12 | "testing" | ||
| 11 | "time" | 13 | "time" | ||
| 12 | 14 | ||||
| 15 | + | "connectrpc.com/connect" | |||
| 13 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 14 | - | "github.com/stretchr/testify/mock" | |||
| 15 | "github.com/stretchr/testify/require" | 17 | "github.com/stretchr/testify/require" | ||
| 18 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |||
| 16 | 19 | ||||
| 17 | - | "bnema.dev/ermite/internal/app" | 20 | + | pb "bnema.dev/ermite-api/gen/ermite" |
| 18 | "bnema.dev/ermite/internal/domain" | 21 | "bnema.dev/ermite/internal/domain" | ||
| 19 | - | "bnema.dev/ermite/internal/ports/mocks" | 22 | + | "bnema.dev/ermite/internal/ports" |
| 23 | + | "bnema.dev/ermite/internal/usecase" | |||
| 20 | ) | 24 | ) | ||
| 21 | 25 | ||||
| 22 | - | // fakeAuth is the Revalidator of the rig: tokens are valid until revoked. | 26 | + | // watchWait is how long a test waits for an event that must arrive. Only |
| 23 | - | type fakeAuth struct { | 27 | + | // TestAC5PushEventWithinASecond holds the server to a second. |
| 24 | - | mu sync.Mutex | 28 | + | const watchWait = 10 * time.Second |
| 25 | - | revoked map[string]bool | 29 | + | |
| 26 | - | calls int | 30 | + | // AC5: a push shows up in Watch within a second, with the ref updates. |
| 31 | + | func TestAC5PushEventWithinASecond(t *testing.T) { | |||
| 32 | + | h := newHarness(t, harnessOpts{}) | |||
| 33 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 34 | + | w := h.watch(h.api(""), "", "home") | |||
| 35 | + | ||||
| 36 | + | wt := h.workTree("main", "a.txt") | |||
| 37 | + | start := time.Now() | |||
| 38 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 39 | + | ev := w.next(t, time.Second) | |||
| 40 | + | assert.Less(t, time.Since(start), 2*time.Second) | |||
| 41 | + | ||||
| 42 | + | pushed := ev.GetPushed() | |||
| 43 | + | require.NotNil(t, pushed, "got %v", ev) | |||
| 44 | + | assert.Equal(t, "pub", pushed.GetRepo()) | |||
| 45 | + | require.Len(t, pushed.GetRefUpdates(), 1) | |||
| 46 | + | up := pushed.GetRefUpdates()[0] | |||
| 47 | + | assert.Equal(t, "refs/heads/main", up.GetRef()) | |||
| 48 | + | assert.Empty(t, up.GetOldId(), "a created ref has no old id") | |||
| 49 | + | assert.Equal(t, h.bareGit("pub", "rev-parse", "main"), up.GetNewId()) | |||
| 50 | + | assert.NotEmpty(t, ev.GetId()) | |||
| 51 | + | assert.NotNil(t, ev.GetTime()) | |||
| 52 | + | ||||
| 53 | + | // A second push to the same ref carries both ids; a deletion has no new id. | |||
| 54 | + | old := up.GetNewId() | |||
| 55 | + | h.writeFile(wt, "b.txt", []byte("b\n")) | |||
| 56 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 57 | + | up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |||
| 58 | + | assert.Equal(t, old, up2.GetOldId()) | |||
| 59 | + | assert.NotEqual(t, old, up2.GetNewId()) | |||
| 60 | + | ||||
| 61 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | |||
| 62 | + | w.next(t, watchWait) | |||
| 63 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | |||
| 64 | + | del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |||
| 65 | + | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | |||
| 66 | + | assert.NotEmpty(t, del.GetOldId()) | |||
| 67 | + | assert.Empty(t, del.GetNewId()) | |||
| 27 | } | 68 | } | ||
| 28 | 69 | ||||
| 29 | - | func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error { | 70 | + | // AC5: creating, updating and deleting repositories through the API publish |
| 30 | - | a.mu.Lock() | 71 | + | // RepoCreated, RepoUpdated and RepoDeleted; so does a create-on-push. |
| 31 | - | defer a.mu.Unlock() | 72 | + | func TestAC5RepoLifecycleEvents(t *testing.T) { |
| 32 | - | a.calls++ | 73 | + | h := newHarness(t, harnessOpts{}) |
| 33 | - | if a.revoked[p.TokenID()] { | 74 | + | root := h.api(adminToken) |
| 34 | - | return domain.ErrUnauthenticated | 75 | + | w := h.watch(root, "") |
| 35 | - | } | 76 | + | |
| 36 | - | return nil | 77 | + | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) |
| 37 | - | } | 78 | + | require.NoError(t, err) |
| 38 | - | 79 | + | created := w.next(t, watchWait).GetRepoCreated().GetRepo() | |
| 39 | - | func (a *fakeAuth) revoke(id string) { | 80 | + | require.NotNil(t, created) |
| 40 | - | a.mu.Lock() | 81 | + | assert.Equal(t, "life", created.GetName()) |
| 41 | - | defer a.mu.Unlock() | 82 | + | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) |
| 42 | - | if a.revoked == nil { | 83 | + | assert.Equal(t, "main", created.GetDefaultBranch()) |
| 43 | - | a.revoked = map[string]bool{} | 84 | + | |
| 44 | - | } | 85 | + | _, err = root.repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ |
| 45 | - | a.revoked[id] = true | 86 | + | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, |
| 46 | - | } | 87 | + | })) |
| 47 | - | 88 | + | require.NoError(t, err) | |
| 48 | - | func (a *fakeAuth) count() int { | 89 | + | updated := w.next(t, watchWait).GetRepoUpdated().GetRepo() |
| 49 | - | a.mu.Lock() | 90 | + | require.NotNil(t, updated) |
| 50 | - | defer a.mu.Unlock() | 91 | + | assert.Equal(t, "new", updated.GetDescription()) |
| 51 | - | return a.calls | 92 | + | |
| 52 | - | } | 93 | + | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) |
| 53 | - | 94 | + | require.NoError(t, err) | |
| 54 | - | // eventRig is an Events use case fed by a channel the test writes to. | 95 | + | assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo()) |
| 55 | - | type eventRig struct { | 96 | + | |
| 56 | - | t *testing.T | 97 | + | // Create on first push publishes RepoCreated once, then the push. |
| 57 | - | meta *mocks.RepoMetaStore | 98 | + | wt := h.workTree("main", "a.txt") |
| 58 | - | git *mocks.GitRepos | 99 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") |
| 59 | - | feed chan domain.Event | 100 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) |
| 60 | - | auth *fakeAuth | 101 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo()) |
| 61 | - | events *app.Events | 102 | + | // Nothing else came in between: the next event is the sentinel. |
| 62 | - | } | 103 | + | h.sentinel(t, w, "s-life") |
| 63 | - | ||||
| 64 | - | func newEventRig(t *testing.T, size int) *eventRig { | |||
| 65 | - | t.Helper() | |||
| 66 | - | return newEventRigConfig(t, app.EventsConfig{Size: size}) | |||
| 67 | } | 104 | } | ||
| 68 | 105 | ||||
| 69 | - | func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig { | 106 | + | // AC5: resume from an id replays what came after, then goes live. |
| 70 | - | t.Helper() | 107 | + | func TestAC5Resume(t *testing.T) { |
| 71 | - | r := &eventRig{ | 108 | + | h := newHarness(t, harnessOpts{}) |
| 72 | - | t: t, | 109 | + | h.seed("pub", domain.VisibilityPublic) |
| 73 | - | meta: mocks.NewRepoMetaStore(t), | 110 | + | api := h.api("") |
| 74 | - | git: mocks.NewGitRepos(t), | 111 | + | w := h.watch(api, "") |
| 75 | - | feed: make(chan domain.Event, 4096), | |||
| 76 | - | auth: &fakeAuth{}, | |||
| 77 | - | } | |||
| 78 | - | cfg.Auth = r.auth | |||
| 79 | - | bus := mocks.NewEventBus(t) | |||
| 80 | - | bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event { | |||
| 81 | - | go func() { <-ctx.Done(); close(r.feed) }() | |||
| 82 | - | return r.feed | |||
| 83 | - | }) | |||
| 84 | - | clock := mocks.NewClock(t) | |||
| 85 | - | clock.EXPECT().Now().Return(now).Maybe() | |||
| 86 | - | r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe() | |||
| 87 | - | repos := app.NewRepos(r.meta, r.git, bus, clock) | |||
| 88 | - | r.events = app.NewEvents(context.Background(), repos, bus, clock, cfg) | |||
| 89 | - | t.Cleanup(func() { r.events.Close(context.Background()) }) | |||
| 90 | - | return r | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | // repo declares a repository the rig knows. | |||
| 94 | - | func (r *eventRig) repo(s string, vis domain.Visibility) domain.RepoName { | |||
| 95 | - | n := name(r.t, s) | |||
| 96 | - | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil).Maybe() | |||
| 97 | - | return n | |||
| 98 | - | } | |||
| 99 | 112 | ||||
| 100 | - | // pushEvent builds the PushEvent the Pushes use case would publish now: it | 113 | + | wt := h.workTree("main", "a.txt") |
| 101 | - | // carries the repository as the store answers at this moment. | 114 | + | var ids []string |
| 102 | - | func (r *eventRig) pushEvent(n domain.RepoName, oid, ref string) domain.PushEvent { | 115 | + | for i := range 4 { |
| 103 | - | repo, err := r.meta.Get(context.Background(), n) | 116 | + | if i > 0 { |
| 104 | - | require.NoError(r.t, err) | 117 | + | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) |
| 105 | - | return domain.PushEvent{ | 118 | + | } |
| 106 | - | Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: ref}}, At: now, | 119 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") |
| 107 | - | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 120 | + | ids = append(ids, w.next(t, watchWait).GetId()) |
| 108 | } | 121 | } | ||
| 109 | - | } | 122 | + | w.cancel() |
| 110 | - | 123 | + | ||
| 111 | - | func (r *eventRig) push(n domain.RepoName, i int) { | 124 | + | // The client lost its connection after ids[1]: it gets 2 and 3, then live. |
| 112 | - | r.feed <- r.pushEvent(n, fmt.Sprintf("%040x", i+1), "refs/heads/main") | 125 | + | r := h.watch(api, ids[1]) |
| 113 | - | } | 126 | + | for _, want := range ids[2:] { |
| 114 | - | 127 | + | ev := r.next(t, watchWait) | |
| 115 | - | // watcher is a running Watch. | 128 | + | assert.Equal(t, want, ev.GetId()) |
| 116 | - | type watcher struct { | 129 | + | assert.NotNil(t, ev.GetPushed()) |
| 117 | - | got chan app.WatchEvent | |||
| 118 | - | done chan error | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | func (r *eventRig) watch(ctx context.Context, p domain.Principal, params app.WatchParams) *watcher { | |||
| 122 | - | w := &watcher{got: make(chan app.WatchEvent, 4096), done: make(chan error, 1)} | |||
| 123 | - | go func() { | |||
| 124 | - | w.done <- r.events.Watch(ctx, p, params, func(e app.WatchEvent) error { | |||
| 125 | - | w.got <- e | |||
| 126 | - | return nil | |||
| 127 | - | }) | |||
| 128 | - | }() | |||
| 129 | - | return w | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | func (w *watcher) next(t *testing.T) app.WatchEvent { | |||
| 133 | - | t.Helper() | |||
| 134 | - | select { | |||
| 135 | - | case e := <-w.got: | |||
| 136 | - | return e | |||
| 137 | - | case err := <-w.done: | |||
| 138 | - | t.Fatalf("watch ended: %v", err) | |||
| 139 | - | case <-time.After(5 * time.Second): | |||
| 140 | - | t.Fatal("no event") | |||
| 141 | } | 130 | } | ||
| 142 | - | return app.WatchEvent{} | 131 | + | // Nothing was replayed twice: the next event is the live one, below. |
| 132 | + | h.writeFile(wt, "live.txt", []byte("x\n")) | |||
| 133 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 134 | + | live := r.next(t, watchWait) | |||
| 135 | + | assert.NotNil(t, live.GetPushed()) | |||
| 136 | + | assert.NotContains(t, ids, live.GetId()) | |||
| 137 | + | ||||
| 138 | + | // An id nobody issued is a gap, followed by the oldest buffered event: the | |||
| 139 | + | // creation of the repository, which came before the first push. | |||
| 140 | + | g := h.watch(api, "not-an-id") | |||
| 141 | + | assert.NotNil(t, g.next(t, watchWait).GetGap()) | |||
| 142 | + | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |||
| 143 | + | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) | |||
| 143 | } | 144 | } | ||
| 144 | 145 | ||||
| 145 | - | // sentinelOID marks the push events that only prove the ones before them were | 146 | + | // AC5: a gap when the id fell out of a small ring. |
| 146 | - | // handled. | 147 | + | func TestAC5GapAfterOverflow(t *testing.T) { |
| 147 | - | var sentinelOID = fmt.Sprintf("%040x", 0xfeed) | 148 | + | h := newHarness(t, harnessOpts{eventBuffer: 4}) |
| 149 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 150 | + | api := h.api(adminToken) | |||
| 151 | + | w := h.watch(api, "") | |||
| 152 | + | wt := h.workTree("main", "a.txt") | |||
| 148 | 153 | ||||
| 149 | - | // quiet proves that the watcher got nothing since its last read: it pushes an | 154 | + | var ids []string |
| 150 | - | // event on n, which the watcher may see, and requires it to be the next one. | 155 | + | for i := range 8 { |
| 151 | - | // Events reach a watcher in order, so anything sent before would show up first. | 156 | + | if i > 0 { |
| 152 | - | func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) { | 157 | + | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) |
| 153 | - | t.Helper() | 158 | + | } |
| 154 | - | r.feed <- r.pushEvent(n, sentinelOID, "refs/heads/sentinel") | 159 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") |
| 155 | - | e := w.next(t) | 160 | + | ids = append(ids, w.next(t, watchWait).GetId()) |
| 156 | - | push, ok := e.Event.(domain.PushEvent) | |||
| 157 | - | if !ok || len(push.Updates) != 1 || push.Updates[0].New != sentinelOID { | |||
| 158 | - | t.Fatalf("expected nothing before the sentinel, got %+v", e) | |||
| 159 | } | 161 | } | ||
| 160 | - | } | |||
| 161 | 162 | ||||
| 162 | - | func TestWatchLiveAndFiltered(t *testing.T) { | 163 | + | // ids[2] is out of the ring (the last four are ids[4:]). |
| 163 | - | r := newEventRig(t, 0) | 164 | + | r := h.watch(api, ids[2]) |
| 164 | - | pub := r.repo("pub", domain.VisibilityPublic) | 165 | + | gap := r.next(t, watchWait) |
| 165 | - | priv := r.repo("priv", domain.VisibilityPrivate) | 166 | + | assert.NotNil(t, gap.GetGap()) |
| 166 | - | ctx, cancel := context.WithCancel(context.Background()) | 167 | + | assert.Empty(t, gap.GetId()) |
| 167 | - | defer cancel() | 168 | + | for _, want := range ids[4:] { |
| 168 | - | 169 | + | assert.Equal(t, want, r.next(t, watchWait).GetId()) | |
| 169 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |||
| 170 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}}) | |||
| 171 | - | scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{}) | |||
| 172 | - | onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}}) | |||
| 173 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 4 }, 5*time.Second, time.Millisecond) | |||
| 174 | - | ||||
| 175 | - | r.push(priv, 1) | |||
| 176 | - | r.push(pub, 2) | |||
| 177 | - | ||||
| 178 | - | // Anonymous and a token limited to another repository never see priv. | |||
| 179 | - | for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} { | |||
| 180 | - | e := w.next(t) | |||
| 181 | - | assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name) | |||
| 182 | - | w.quiet(t, r, pub) | |||
| 183 | } | 170 | } | ||
| 184 | - | first, second := root.next(t), root.next(t) | |||
| 185 | - | assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo) | |||
| 186 | - | assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo) | |||
| 187 | - | assert.NotEmpty(t, first.ID) | |||
| 188 | - | assert.NotEqual(t, first.ID, second.ID) | |||
| 189 | - | ||||
| 190 | - | assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo) | |||
| 191 | - | onlyPriv.quiet(t, r, priv) | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | func TestWatchRechecksAccessPerEvent(t *testing.T) { | |||
| 195 | - | r := newEventRig(t, 0) | |||
| 196 | - | n := name(t, "flip") | |||
| 197 | - | vis := domain.VisibilityPublic | |||
| 198 | - | var mu sync.Mutex | |||
| 199 | - | r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |||
| 200 | - | mu.Lock() | |||
| 201 | - | defer mu.Unlock() | |||
| 202 | - | return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil | |||
| 203 | - | }) | |||
| 204 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 205 | - | defer cancel() | |||
| 206 | - | w := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |||
| 207 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 208 | - | ||||
| 209 | - | r.push(n, 1) | |||
| 210 | - | w.next(t) | |||
| 211 | 171 | ||||
| 212 | - | mu.Lock() | 172 | + | // The last buffered id still resumes without a gap. |
| 213 | - | vis = domain.VisibilityPrivate | 173 | + | ok := h.watch(api, ids[4]) |
| 214 | - | mu.Unlock() | 174 | + | assert.Equal(t, ids[5], ok.next(t, watchWait).GetId()) |
| 215 | - | r.push(n, 2) | |||
| 216 | - | w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous | |||
| 217 | } | 175 | } | ||
| 218 | 176 | ||||
| 219 | - | func TestWatchResumeAndGap(t *testing.T) { | 177 | + | // AC5: private events never reach anonymous callers, nor tokens that may not |
| 220 | - | const size = 8 | 178 | + | // read the repository; repo topics follow the same rule. |
| 221 | - | r := newEventRig(t, size) | 179 | + | func TestAC5NoPrivateEventsToAnonymous(t *testing.T) { |
| 222 | - | n := r.repo("pub", domain.VisibilityPublic) | 180 | + | h := newHarness(t, harnessOpts{}) |
| 223 | - | ctx, cancel := context.WithCancel(context.Background()) | 181 | + | h.seed("pub", domain.VisibilityPublic) |
| 224 | - | defer cancel() | 182 | + | h.seed("priv", domain.VisibilityPrivate) |
| 225 | - | 183 | + | other := h.token(scopes(domain.ScopeRepoRead), "pub") | |
| 226 | - | // Record the ids of 20 events through a first watcher. | 184 | + | reader := h.token(scopes(domain.ScopeRepoRead), "priv") |
| 227 | - | first := r.watch(ctx, domain.Root(), app.WatchParams{}) | 185 | + | |
| 228 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | 186 | + | anon := h.watch(h.api(""), "") |
| 229 | - | // One at a time: this watcher must not fall behind the small ring. | 187 | + | scoped := h.watch(h.api(other), "") |
| 230 | - | var ids []string | 188 | + | rd := h.watch(h.api(reader), "") |
| 231 | - | for i := range 20 { | 189 | + | root := h.watch(h.api(adminToken), "") |
| 232 | - | r.push(n, i) | 190 | + | |
| 233 | - | ids = append(ids, first.next(t).ID) | 191 | + | // A private repository's topic is not found for those who cannot read it. |
| 192 | + | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |||
| 193 | + | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(other), "repo:priv"))) | |||
| 194 | + | assert.Equal(t, codeName(watchErr(h.ctx, h.api(""), "repo:nothing")), codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |||
| 195 | + | ||||
| 196 | + | wt := h.workTree("main", "a.txt") | |||
| 197 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |||
| 198 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 199 | + | // Metadata events too. | |||
| 200 | + | _, err := h.api(adminToken).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 201 | + | Repo: "priv", Description: "secret plans", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |||
| 202 | + | })) | |||
| 203 | + | require.NoError(t, err) | |||
| 204 | + | ||||
| 205 | + | // The reader of priv sees its push and its update, and pub's push. | |||
| 206 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo()) | |||
| 207 | + | assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public") | |||
| 208 | + | assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription()) | |||
| 209 | + | // Root sees everything in order. | |||
| 210 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo()) | |||
| 211 | + | assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo()) | |||
| 212 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName()) | |||
| 213 | + | ||||
| 214 | + | // Deleting reaches those who could read the repository before, not others. | |||
| 215 | + | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | |||
| 216 | + | require.NoError(t, err) | |||
| 217 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo()) | |||
| 218 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo()) | |||
| 219 | + | ||||
| 220 | + | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |||
| 221 | + | ev := w.next(t, watchWait) | |||
| 222 | + | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | |||
| 234 | } | 223 | } | ||
| 235 | - | require.Len(t, ids, 20) | 224 | + | // Nothing about priv came before the sentinel, for either of them. |
| 236 | - | 225 | + | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic}) | |
| 237 | - | // The subtests that read the state of the ring come first: the ones that | 226 | + | require.NoError(t, err) |
| 238 | - | // need a sentinel add events to it. | 227 | + | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { |
| 239 | - | t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) { | 228 | + | assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name) |
| 240 | - | // 20 events were taken, the buffer holds the last 8: ids[12] is the | |||
| 241 | - | // oldest still present; ids[0] is long gone. | |||
| 242 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]}) | |||
| 243 | - | gap := w.next(t) | |||
| 244 | - | assert.True(t, gap.Gap) | |||
| 245 | - | assert.Empty(t, gap.ID) | |||
| 246 | - | oldest := w.next(t) | |||
| 247 | - | assert.False(t, oldest.Gap) | |||
| 248 | - | assert.Equal(t, ids[12], oldest.ID) | |||
| 249 | - | }) | |||
| 250 | - | ||||
| 251 | - | t.Run("an unknown id gives a gap", func(t *testing.T) { | |||
| 252 | - | for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} { | |||
| 253 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id}) | |||
| 254 | - | assert.True(t, w.next(t).Gap, id) | |||
| 255 | - | } | |||
| 256 | - | }) | |||
| 257 | - | ||||
| 258 | - | t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) { | |||
| 259 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"}) | |||
| 260 | - | assert.True(t, w.next(t).Gap) | |||
| 261 | - | }) | |||
| 262 | - | ||||
| 263 | - | t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) { | |||
| 264 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]}) | |||
| 265 | - | for _, want := range ids[17:] { | |||
| 266 | - | e := w.next(t) | |||
| 267 | - | assert.False(t, e.Gap) | |||
| 268 | - | assert.Equal(t, want, e.ID) | |||
| 269 | - | } | |||
| 270 | - | // Nothing else was replayed: the next event is the one pushed now. | |||
| 271 | - | r.push(n, 98) | |||
| 272 | - | live := w.next(t) | |||
| 273 | - | assert.False(t, live.Gap) | |||
| 274 | - | assert.NotContains(t, ids, live.ID) | |||
| 275 | - | ids = append(ids, live.ID) | |||
| 276 | - | }) | |||
| 277 | - | ||||
| 278 | - | t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) { | |||
| 279 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]}) | |||
| 280 | - | r.push(n, 99) | |||
| 281 | - | e := w.next(t) | |||
| 282 | - | assert.False(t, e.Gap) | |||
| 283 | - | assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed") | |||
| 284 | - | }) | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | func TestWatchTopicErrors(t *testing.T) { | |||
| 288 | - | r := newEventRig(t, 0) | |||
| 289 | - | r.repo("pub", domain.VisibilityPublic) | |||
| 290 | - | r.repo("priv", domain.VisibilityPrivate) | |||
| 291 | - | r.meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound).Maybe() | |||
| 292 | - | noop := func(app.WatchEvent) error { return nil } | |||
| 293 | - | ctx := context.Background() | |||
| 294 | - | ||||
| 295 | - | for topic, want := range map[string]error{ | |||
| 296 | - | "nope": domain.ErrInvalid, | |||
| 297 | - | "repo:Bad": domain.ErrInvalidName, | |||
| 298 | - | "repo:": domain.ErrInvalidName, | |||
| 299 | - | "repo:gone": domain.ErrNotFound, | |||
| 300 | - | "repo:priv": domain.ErrNotFound, // hidden from anonymous | |||
| 301 | - | "repo:pub:foo": domain.ErrInvalidName, | |||
| 302 | - | } { | |||
| 303 | - | err := r.events.Watch(ctx, domain.Anonymous(), app.WatchParams{Topics: []string{topic}}, noop) | |||
| 304 | - | assert.ErrorIs(t, err, want, topic) | |||
| 305 | } | 229 | } | ||
| 306 | - | } | |||
| 307 | - | ||||
| 308 | - | func TestWatchEndsCleanly(t *testing.T) { | |||
| 309 | - | t.Run("on context cancellation", func(t *testing.T) { | |||
| 310 | - | r := newEventRig(t, 0) | |||
| 311 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 312 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{}) | |||
| 313 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 314 | - | cancel() | |||
| 315 | - | select { | |||
| 316 | - | case err := <-w.done: | |||
| 317 | - | assert.NoError(t, err) | |||
| 318 | - | case <-time.After(5 * time.Second): | |||
| 319 | - | t.Fatal("watch did not end") | |||
| 320 | - | } | |||
| 321 | - | assert.Zero(t, r.events.Watchers()) | |||
| 322 | - | }) | |||
| 323 | - | ||||
| 324 | - | t.Run("on Close", func(t *testing.T) { | |||
| 325 | - | r := newEventRig(t, 0) | |||
| 326 | - | w := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | |||
| 327 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 328 | - | r.events.Close(context.Background()) | |||
| 329 | - | r.events.Close(context.Background()) // twice is fine | |||
| 330 | - | select { | |||
| 331 | - | case err := <-w.done: | |||
| 332 | - | assert.NoError(t, err) | |||
| 333 | - | case <-time.After(5 * time.Second): | |||
| 334 | - | t.Fatal("watch did not end") | |||
| 335 | - | } | |||
| 336 | - | }) | |||
| 337 | 230 | ||||
| 338 | - | t.Run("a failing send ends the watch with its error", func(t *testing.T) { | 231 | + | // Resuming does not replay private events to anonymous either: after a |
| 339 | - | r := newEventRig(t, 0) | 232 | + | // gap, the creation and the push of pub, the sentinels the watchers above |
| 340 | - | n := r.repo("pub", domain.VisibilityPublic) | 233 | + | // saw, and nothing about priv. |
| 341 | - | boom := errors.New("client went away") | 234 | + | r := h.watch(h.api(""), "bogus") |
| 342 | - | done := make(chan error, 1) | 235 | + | assert.NotNil(t, r.next(t, watchWait).GetGap()) |
| 343 | - | go func() { | 236 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) |
| 344 | - | done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom }) | 237 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo()) |
| 345 | - | }() | 238 | + | assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) |
| 346 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | 239 | + | h.sentinel(t, r, "s-resumed") |
| 347 | - | r.push(n, 1) | |||
| 348 | - | assert.ErrorIs(t, <-done, boom) | |||
| 349 | - | }) | |||
| 350 | } | 240 | } | ||
| 351 | 241 | ||||
| 352 | - | func TestWatchSlowWatcherGetsAGap(t *testing.T) { | 242 | + | // AC5: a repo topic only carries that repository; a revoked token cannot |
| 353 | - | const size = 4 | 243 | + | // start a new watch. |
| 354 | - | r := newEventRig(t, size) | 244 | + | func TestAC5RepoTopic(t *testing.T) { |
| 355 | - | n := r.repo("pub", domain.VisibilityPublic) | 245 | + | h := newHarness(t, harnessOpts{}) |
| 356 | - | 246 | + | h.seed("a", domain.VisibilityPublic) | |
| 357 | - | release := make(chan struct{}) | 247 | + | h.seed("b", domain.VisibilityPublic) |
| 358 | - | var mu sync.Mutex | 248 | + | w := h.watch(h.api(""), "", "repo:a") |
| 359 | - | var got []app.WatchEvent | 249 | + | |
| 360 | - | done := make(chan error, 1) | 250 | + | wt := h.workTree("main", "x.txt") |
| 361 | - | blocked := make(chan struct{}) | 251 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") |
| 362 | - | first := true | 252 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") |
| 363 | - | go func() { | 253 | + | assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo()) |
| 364 | - | done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error { | 254 | + | // The push to b was not delivered: the next event is the next push to a. |
| 365 | - | if first { // block on the first event while the ring turns over | 255 | + | h.writeFile(wt, "y.txt", []byte("y\n")) |
| 366 | - | first = false | 256 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") |
| 367 | - | close(blocked) | 257 | + | assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId()) |
| 368 | - | <-release | |||
| 369 | - | } | |||
| 370 | - | mu.Lock() | |||
| 371 | - | got = append(got, e) | |||
| 372 | - | mu.Unlock() | |||
| 373 | - | return nil | |||
| 374 | - | }) | |||
| 375 | - | }() | |||
| 376 | - | // A second, fast watcher tells when the use case has taken every event: | |||
| 377 | - | // it receives them in order. | |||
| 378 | - | fast := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | |||
| 379 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | |||
| 380 | - | r.push(n, 0) | |||
| 381 | - | <-blocked | |||
| 382 | - | for i := 1; i < 30; i++ { | |||
| 383 | - | r.push(n, i) | |||
| 384 | - | } | |||
| 385 | - | // The ring holds four events: the fast watcher may get a gap, but it ends | |||
| 386 | - | // on the last push, which proves the use case took all of them. | |||
| 387 | - | last := fmt.Sprintf("%040x", 29+1) | |||
| 388 | - | for { | |||
| 389 | - | e := fast.next(t) | |||
| 390 | - | if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last { | |||
| 391 | - | break | |||
| 392 | - | } | |||
| 393 | - | } | |||
| 394 | - | close(release) | |||
| 395 | - | ||||
| 396 | - | require.Eventually(t, func() bool { | |||
| 397 | - | mu.Lock() | |||
| 398 | - | defer mu.Unlock() | |||
| 399 | - | return len(got) >= 2 | |||
| 400 | - | }, 5*time.Second, time.Millisecond) | |||
| 401 | - | mu.Lock() | |||
| 402 | - | defer mu.Unlock() | |||
| 403 | - | hasGap := false | |||
| 404 | - | for _, e := range got[1:] { | |||
| 405 | - | hasGap = hasGap || e.Gap | |||
| 406 | - | } | |||
| 407 | - | assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got) | |||
| 408 | } | 258 | } | ||
| 409 | 259 | ||||
| 410 | - | func TestEventsRepoPayloads(t *testing.T) { | 260 | + | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. |
| 411 | - | r := newEventRig(t, 0) | 261 | + | func TestWatchEndsOnCancel(t *testing.T) { |
| 412 | - | pub := r.repo("pub", domain.VisibilityPublic) | 262 | + | h := newHarness(t, harnessOpts{}) |
| 413 | - | priv := r.repo("priv", domain.VisibilityPrivate) | 263 | + | w := h.watch(h.api(""), "") |
| 414 | - | ctx, cancel := context.WithCancel(context.Background()) | 264 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) |
| 415 | - | defer cancel() | 265 | + | w.cancel() |
| 416 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | 266 | + | select { |
| 417 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | 267 | + | case <-w.done: |
| 418 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | 268 | + | case <-time.After(5 * watchWait): |
| 419 | - | 269 | + | t.Fatal("the stream did not end") | |
| 420 | - | r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now} | |||
| 421 | - | r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now} | |||
| 422 | - | // A deleted repository cannot be read any more: its visibility decides. | |||
| 423 | - | r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now} | |||
| 424 | - | r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now} | |||
| 425 | - | ||||
| 426 | - | for _, w := range []*watcher{anon, root} { | |||
| 427 | - | created := w.next(t) | |||
| 428 | - | require.NotNil(t, created.Repo) | |||
| 429 | - | assert.Equal(t, "main", created.Repo.DefaultBranch) | |||
| 430 | - | assert.Equal(t, pub, created.Repo.Name) | |||
| 431 | - | require.NotNil(t, w.next(t).Repo) | |||
| 432 | } | 270 | } | ||
| 433 | - | assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo) | 271 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) |
| 434 | - | assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo) | |||
| 435 | - | assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped") | |||
| 436 | - | anon.quiet(t, r, pub) | |||
| 437 | } | 272 | } | ||
| 438 | 273 | ||||
| 439 | - | // tokenWith builds the principal of a stored token with an id and an expiry. | 274 | + | // Shutdown ends the streams cleanly: the client sees the stream end without |
| 440 | - | func tokenWith(id string, expires time.Time, repos ...string) domain.Principal { | 275 | + | // an error and the server exits 0 well before the shutdown timeout. |
| 441 | - | tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires} | 276 | + | func TestServeShutdownEndsWatchStreams(t *testing.T) { |
| 442 | - | for _, r := range repos { | 277 | + | requireGit(t) |
| 443 | - | n, err := domain.ParseRepoName(r) | 278 | + | data := t.TempDir() |
| 279 | + | addr := freeAddr(t) | |||
| 280 | + | cmd := exec.Command(binary(t), "serve") | |||
| 281 | + | cmd.Env = append(baseEnv(), | |||
| 282 | + | "ERMITE_ADDR="+addr, "ERMITE_DATA_DIR="+data, "ERMITE_PUBLIC_URL=http://"+addr, | |||
| 283 | + | "ERMITE_ADMIN_TOKEN="+adminToken, "ERMITE_LOG_LEVEL=debug", "ERMITE_LOG_FORMAT=json", | |||
| 284 | + | ) | |||
| 285 | + | var logs syncBuffer | |||
| 286 | + | cmd.Stdout, cmd.Stderr = &logs, &logs | |||
| 287 | + | require.NoError(t, cmd.Start()) | |||
| 288 | + | exited := make(chan error, 1) | |||
| 289 | + | go func() { exited <- cmd.Wait() }() | |||
| 290 | + | t.Cleanup(func() { _ = cmd.Process.Kill() }) | |||
| 291 | + | ||||
| 292 | + | base := "http://" + addr | |||
| 293 | + | require.Eventually(t, func() bool { | |||
| 294 | + | resp, err := http.Get(base + "/-/health") | |||
| 444 | if err != nil { | 295 | if err != nil { | ||
| 445 | - | panic(err) | 296 | + | return false |
| 446 | } | 297 | } | ||
| 447 | - | tok.Repos = append(tok.Repos, n) | 298 | + | _ = resp.Body.Close() |
| 448 | - | } | 299 | + | return resp.StatusCode == 200 |
| 449 | - | return domain.TokenPrincipal(tok) | 300 | + | }, 15*time.Second, 50*time.Millisecond, "%s", logs.String()) |
| 450 | - | } | |||
| 451 | 301 | ||||
| 452 | - | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | 302 | + | c := newClients(base+"/-/api", adminToken) |
| 453 | - | // Asking at every turn keeps the test independent of the clock. | |||
| 454 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1}) | |||
| 455 | - | pub := r.repo("pub", domain.VisibilityPublic) | |||
| 456 | - | priv := r.repo("priv", domain.VisibilityPrivate) | |||
| 457 | - | p := tokenWith("tok", time.Time{}) | |||
| 458 | ctx, cancel := context.WithCancel(context.Background()) | 303 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 459 | defer cancel() | 304 | defer cancel() | ||
| 305 | + | st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |||
| 306 | + | require.NoError(t, err) | |||
| 307 | + | ended := make(chan error, 1) | |||
| 308 | + | go func() { | |||
| 309 | + | for st.Receive() { | |||
| 310 | + | } | |||
| 311 | + | ended <- st.Err() | |||
| 312 | + | }() | |||
| 460 | 313 | ||||
| 461 | - | w := r.watch(ctx, p, app.WatchParams{}) | 314 | + | // The server version comes from the real binary. |
| 462 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | 315 | + | v, err := c.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) |
| 463 | - | r.push(pub, 1) | 316 | + | require.NoError(t, err) |
| 464 | - | w.next(t) | 317 | + | assert.NotEmpty(t, v.Msg.GetVersion()) |
| 465 | 318 | ||||
| 466 | - | r.auth.revoke("tok") | 319 | + | start := time.Now() |
| 467 | - | r.push(priv, 2) // would be delivered to a token that may read priv | 320 | + | require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) |
| 468 | - | select { | |||
| 469 | - | case err := <-w.done: | |||
| 470 | - | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 471 | - | case <-time.After(5 * time.Second): | |||
| 472 | - | t.Fatal("the watch of a revoked token did not end") | |||
| 473 | - | } | |||
| 474 | select { | 321 | select { | ||
| 475 | - | case e := <-w.got: | 322 | + | case err := <-ended: |
| 476 | - | t.Fatalf("an event was sent after the revocation: %+v", e) | 323 | + | assert.NoError(t, err, "the stream ends cleanly") |
| 477 | - | default: | 324 | + | case <-time.After(15 * watchWait): |
| 325 | + | t.Fatalf("the stream did not end\n%s", logs.String()) | |||
| 478 | } | 326 | } | ||
| 479 | - | } | |||
| 480 | - | ||||
| 481 | - | func TestWatchEndsWhenTheTokenExpires(t *testing.T) { | |||
| 482 | - | r := newEventRig(t, 0) | |||
| 483 | - | pub := r.repo("pub", domain.VisibilityPublic) | |||
| 484 | - | // The rig's clock stands at now: this token expired at now. | |||
| 485 | - | p := tokenWith("tok", now) | |||
| 486 | - | w := r.watch(context.Background(), p, app.WatchParams{}) | |||
| 487 | - | r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated | |||
| 488 | select { | 327 | select { | ||
| 489 | - | case err := <-w.done: | 328 | + | case err := <-exited: |
| 490 | - | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | 329 | + | require.NoError(t, err, "%s", logs.String()) |
| 491 | - | case <-time.After(5 * time.Second): | 330 | + | case <-time.After(15 * watchWait): |
| 492 | - | t.Fatal("the watch of an expired token did not end") | 331 | + | t.Fatalf("the server did not exit\n%s", logs.String()) |
| 493 | } | 332 | } | ||
| 494 | - | assert.Empty(t, w.got) | 333 | + | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") |
| 334 | + | assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String()) | |||
| 335 | + | assert.False(t, errors.Is(err, context.Canceled)) | |||
| 495 | } | 336 | } | ||
| 496 | 337 | ||||
| 497 | - | func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | 338 | + | // A token that is revoked while its Watch is open ends the stream with |
| 498 | - | t.Run("a quiet forge still notices a revocation", func(t *testing.T) { | 339 | + | // unauthenticated, and the private events that follow never reach it. |
| 499 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond}) | 340 | + | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { |
| 500 | - | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | 341 | + | h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond}) |
| 501 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | 342 | + | h.seed("priv", domain.VisibilityPrivate) |
| 502 | - | r.auth.revoke("tok") | 343 | + | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{ |
| 503 | - | select { | 344 | + | Label: "watcher", Scopes: scopes(domain.ScopeRepoRead), |
| 504 | - | case err := <-w.done: | |||
| 505 | - | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 506 | - | case <-time.After(5 * time.Second): | |||
| 507 | - | t.Fatal("an idle watch of a revoked token did not end") | |||
| 508 | - | } | |||
| 509 | }) | 345 | }) | ||
| 346 | + | require.NoError(t, err) | |||
| 347 | + | w := h.watch(h.api(created.Secret), "") | |||
| 348 | + | wt := h.workTree("main", "a.txt") | |||
| 349 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |||
| 350 | + | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") | |||
| 510 | 351 | ||||
| 511 | - | t.Run("the answer is kept between checks", func(t *testing.T) { | 352 | + | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) |
| 512 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour}) | 353 | + | h.writeFile(wt, "b.txt", []byte("b\n")) |
| 513 | - | pub := r.repo("pub", domain.VisibilityPublic) | 354 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") |
| 514 | - | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | |||
| 515 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 516 | - | for i := range 20 { | |||
| 517 | - | r.push(pub, i) | |||
| 518 | - | } | |||
| 519 | - | for range 20 { | |||
| 520 | - | w.next(t) | |||
| 521 | - | } | |||
| 522 | - | assert.Zero(t, r.auth.count(), "no store lookup within the interval") | |||
| 523 | - | }) | |||
| 524 | 355 | ||||
| 525 | - | t.Run("root and anonymous never ask", func(t *testing.T) { | 356 | + | select { |
| 526 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1}) | 357 | + | case <-w.done: |
| 527 | - | pub := r.repo("pub", domain.VisibilityPublic) | 358 | + | case <-time.After(5 * watchWait): |
| 528 | - | a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{}) | 359 | + | t.Fatal("the stream of a revoked token did not end") |
| 529 | - | b := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | 360 | + | } |
| 530 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | 361 | + | assert.Equal(t, "unauthenticated", codeName(w.err)) |
| 531 | - | r.push(pub, 1) | 362 | + | for ev := range w.events { |
| 532 | - | a.next(t) | 363 | + | t.Fatalf("an event reached the revoked token: %v", ev) |
| 533 | - | b.next(t) | 364 | + | } |
| 534 | - | assert.Zero(t, r.auth.count()) | |||
| 535 | - | }) | |||
| 536 | } | 365 | } | ||
| 537 | 366 | ||||
| 367 | + | // Watch refuses too many topics, and too many streams from one client. | |||
| 538 | func TestWatchLimits(t *testing.T) { | 368 | func TestWatchLimits(t *testing.T) { | ||
| 539 | - | noop := func(app.WatchEvent) error { return nil } | 369 | + | h := newHarness(t, harnessOpts{}) |
| 540 | - | 370 | + | api := h.api(adminToken) | |
| 541 | - | t.Run("too many topics", func(t *testing.T) { | |||
| 542 | - | r := newEventRig(t, 0) | |||
| 543 | - | r.repo("pub", domain.VisibilityPublic) | |||
| 544 | - | ok := make([]string, app.MaxWatchTopics) | |||
| 545 | - | for i := range ok { | |||
| 546 | - | ok[i] = "home" | |||
| 547 | - | } | |||
| 548 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 549 | - | cancel() // returns at once once the topics are accepted | |||
| 550 | - | assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop)) | |||
| 551 | - | err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop) | |||
| 552 | - | assert.ErrorIs(t, err, domain.ErrInvalid) | |||
| 553 | - | }) | |||
| 554 | - | ||||
| 555 | - | t.Run("total watchers", func(t *testing.T) { | |||
| 556 | - | r := newEventRig(t, 0) | |||
| 557 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 558 | - | defer cancel() | |||
| 559 | - | for range app.MaxWatchers { | |||
| 560 | - | r.watch(ctx, domain.Root(), app.WatchParams{}) | |||
| 561 | - | } | |||
| 562 | - | require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchers }, 5*time.Second, time.Millisecond) | |||
| 563 | - | err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop) | |||
| 564 | - | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |||
| 565 | - | assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted") | |||
| 566 | - | ||||
| 567 | - | cancel() // all end; a new one is accepted again | |||
| 568 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |||
| 569 | - | again, stop := context.WithCancel(context.Background()) | |||
| 570 | - | stop() | |||
| 571 | - | assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop)) | |||
| 572 | - | }) | |||
| 573 | - | ||||
| 574 | - | t.Run("watchers per client", func(t *testing.T) { | |||
| 575 | - | r := newEventRig(t, 0) | |||
| 576 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 577 | - | defer cancel() | |||
| 578 | - | for range app.MaxWatchersPerClient { | |||
| 579 | - | r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}) | |||
| 580 | - | } | |||
| 581 | - | require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |||
| 582 | - | ||||
| 583 | - | err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop) | |||
| 584 | - | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |||
| 585 | - | ||||
| 586 | - | // Another client, and callers in process, are not affected. | |||
| 587 | - | r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"}) | |||
| 588 | - | r.watch(ctx, domain.Root(), app.WatchParams{}) | |||
| 589 | - | require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient+2 }, 5*time.Second, time.Millisecond) | |||
| 590 | - | ||||
| 591 | - | cancel() | |||
| 592 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |||
| 593 | - | again, stop := context.WithCancel(context.Background()) | |||
| 594 | - | stop() | |||
| 595 | - | assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released") | |||
| 596 | - | }) | |||
| 597 | - | } | |||
| 598 | 371 | ||||
| 599 | - | // repoAt declares a repository whose metadata the test changes. | 372 | + | topics := make([]string, usecase.MaxWatchTopics+1) |
| 600 | - | type liveRepo struct { | 373 | + | for i := range topics { |
| 601 | - | mu sync.Mutex | 374 | + | topics[i] = "home" |
| 602 | - | repo domain.Repo | 375 | + | } |
| 603 | - | } | 376 | + | assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...))) |
| 604 | - | ||||
| 605 | - | func (l *liveRepo) set(f func(*domain.Repo)) { | |||
| 606 | - | l.mu.Lock() | |||
| 607 | - | defer l.mu.Unlock() | |||
| 608 | - | f(&l.repo) | |||
| 609 | - | } | |||
| 610 | - | ||||
| 611 | - | func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time) (*liveRepo, domain.RepoName) { | |||
| 612 | - | n := name(r.t, s) | |||
| 613 | - | l := &liveRepo{repo: domain.Repo{Name: n, Visibility: vis, CreatedAt: created}} | |||
| 614 | - | r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |||
| 615 | - | l.mu.Lock() | |||
| 616 | - | defer l.mu.Unlock() | |||
| 617 | - | return l.repo, nil | |||
| 618 | - | }).Maybe() | |||
| 619 | - | return l, n | |||
| 620 | - | } | |||
| 621 | 377 | ||||
| 622 | - | func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) { | 378 | + | for range usecase.MaxWatchersPerClient { |
| 623 | - | ctx, cancel := context.WithCancel(context.Background()) | 379 | + | h.watch(api, "") |
| 380 | + | } | |||
| 381 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == usecase.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |||
| 382 | + | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | |||
| 383 | + | // A stream of another client address is not affected (the harness trusts | |||
| 384 | + | // the loopback proxy only when configured: ask the use case directly). | |||
| 385 | + | ctx, cancel := context.WithCancel(h.ctx) | |||
| 624 | defer cancel() | 386 | defer cancel() | ||
| 625 | - | 387 | + | registered := false | |
| 626 | - | t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) { | 388 | + | err := h.forge.EventLog.Watch(ctx, domain.Root(), ports.WatchParams{ |
| 627 | - | r := newEventRig(t, 0) | 389 | + | Client: "another-client", Ready: func() { registered = true; cancel() }, |
| 628 | - | l, n := r.liveRepo("secret", domain.VisibilityPrivate, now) | 390 | + | }, func(ports.WatchEvent) error { return nil }) |
| 629 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | 391 | + | require.NoError(t, err) |
| 630 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | 392 | + | assert.True(t, registered, "a watch of another client address is registered") |
| 631 | - | r.push(n, 1) | |||
| 632 | - | r.push(n, 2) | |||
| 633 | - | root.next(t) | |||
| 634 | - | root.next(t) // both are buffered now | |||
| 635 | - | ||||
| 636 | - | l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }) | |||
| 637 | - | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now} | |||
| 638 | - | root.next(t) | |||
| 639 | - | r.push(n, 3) // public era | |||
| 640 | - | ||||
| 641 | - | // An anonymous caller that resumes with an id nobody issued gets what | |||
| 642 | - | // is buffered, filtered: the public update and the public push, not the | |||
| 643 | - | // pushes of the private era. | |||
| 644 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"}) | |||
| 645 | - | assert.True(t, anon.next(t).Gap) | |||
| 646 | - | upd := anon.next(t) | |||
| 647 | - | require.IsType(t, domain.RepoUpdated{}, upd.Event) | |||
| 648 | - | push := anon.next(t) | |||
| 649 | - | require.IsType(t, domain.PushEvent{}, push.Event) | |||
| 650 | - | assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New) | |||
| 651 | - | anon.quiet(t, r, n) | |||
| 652 | - | }) | |||
| 653 | - | ||||
| 654 | - | t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) { | |||
| 655 | - | r := newEventRig(t, 0) | |||
| 656 | - | first := now.Add(-time.Hour) | |||
| 657 | - | l, n := r.liveRepo("again", domain.VisibilityPublic, first) | |||
| 658 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |||
| 659 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | |||
| 660 | - | r.push(n, 1) | |||
| 661 | - | root.next(t) | |||
| 662 | - | ||||
| 663 | - | l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again | |||
| 664 | - | r.push(n, 2) | |||
| 665 | - | root.next(t) | |||
| 666 | - | ||||
| 667 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"}) | |||
| 668 | - | assert.True(t, anon.next(t).Gap) | |||
| 669 | - | e := anon.next(t) | |||
| 670 | - | require.IsType(t, domain.PushEvent{}, e.Event) | |||
| 671 | - | assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository") | |||
| 672 | - | anon.quiet(t, r, n) | |||
| 673 | - | }) | |||
| 674 | - | ||||
| 675 | - | t.Run("a repository made private tells those who could read it, once", func(t *testing.T) { | |||
| 676 | - | r := newEventRig(t, 0) | |||
| 677 | - | l, n := r.liveRepo("hide", domain.VisibilityPublic, now) | |||
| 678 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |||
| 679 | - | reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{}) | |||
| 680 | - | other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{}) | |||
| 681 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |||
| 682 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 4 }, 5*time.Second, time.Millisecond) | |||
| 683 | - | ||||
| 684 | - | l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate }) | |||
| 685 | - | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now} | |||
| 686 | - | ||||
| 687 | - | for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} { | |||
| 688 | - | e := w.next(t) | |||
| 689 | - | up, ok := e.Event.(domain.RepoUpdated) | |||
| 690 | - | require.True(t, ok, name) | |||
| 691 | - | assert.Equal(t, n, up.Repo, name) | |||
| 692 | - | require.NotNil(t, e.Repo, name) | |||
| 693 | - | assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name) | |||
| 694 | - | assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name) | |||
| 695 | - | } | |||
| 696 | - | ||||
| 697 | - | // Later events of the repository reach those who may read it now, only. | |||
| 698 | - | r.push(n, 1) | |||
| 699 | - | assert.IsType(t, domain.PushEvent{}, reader.next(t).Event) | |||
| 700 | - | assert.IsType(t, domain.PushEvent{}, root.next(t).Event) | |||
| 701 | - | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |||
| 702 | - | other.quiet(t, r, r.repo("open2", domain.VisibilityPublic)) | |||
| 703 | - | }) | |||
| 704 | - | ||||
| 705 | - | t.Run("a private repository that never was public stays hidden", func(t *testing.T) { | |||
| 706 | - | r := newEventRig(t, 0) | |||
| 707 | - | _, n := r.liveRepo("closed", domain.VisibilityPrivate, now) | |||
| 708 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |||
| 709 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | |||
| 710 | - | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now} | |||
| 711 | - | r.push(n, 1) | |||
| 712 | - | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |||
| 713 | - | }) | |||
| 714 | } | 393 | } | ||
| 715 | 394 | ||||
| 716 | - | // TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was | 395 | + | // sentinel proves that nothing reached the stream since the last event it |
| 717 | - | // when published, not as the store says when the feeder gets to it. | 396 | + | // returned: it creates a public repository, which every watcher of "home" may |
| 718 | - | func TestPushEventsKeepTheirSnapshot(t *testing.T) { | 397 | + | // see, and requires its RepoCreated to be the next event. Events arrive in the |
| 719 | - | ctx, cancel := context.WithCancel(context.Background()) | 398 | + | // order they were published. |
| 720 | - | defer cancel() | 399 | + | func (h *harness) sentinel(t *testing.T, s *stream, name string) { |
| 721 | - | 400 | + | t.Helper() | |
| 722 | - | tests := []struct { | 401 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) |
| 723 | - | name string | 402 | + | require.NoError(t, err) |
| 724 | - | swap func(repo *domain.Repo) | 403 | + | ev := s.next(t, watchWait) |
| 725 | - | rootSees bool // the repository is still the one the push is about | 404 | + | assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev) |
| 726 | - | }{ | |||
| 727 | - | {"made public before the feeder appends", func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }, true}, | |||
| 728 | - | {"recreated before the feeder appends", func(repo *domain.Repo) { | |||
| 729 | - | repo.Visibility = domain.VisibilityPublic | |||
| 730 | - | repo.CreatedAt = now.Add(time.Hour) | |||
| 731 | - | }, false}, | |||
| 732 | - | } | |||
| 733 | - | for _, tt := range tests { | |||
| 734 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 735 | - | r := newEventRig(t, 0) | |||
| 736 | - | l, n := r.liveRepo("secret", domain.VisibilityPrivate, now) | |||
| 737 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |||
| 738 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |||
| 739 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 2 }, 5*time.Second, time.Millisecond) | |||
| 740 | - | ||||
| 741 | - | // Published while private; the store changes before the feeder | |||
| 742 | - | // can have looked at it: the event is built, then the state is | |||
| 743 | - | // swapped, and only then is it handed to the feeder. | |||
| 744 | - | ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main") | |||
| 745 | - | l.set(tt.swap) | |||
| 746 | - | r.feed <- ev | |||
| 747 | - | if tt.rootSees { | |||
| 748 | - | root.next(t) | |||
| 749 | - | } | |||
| 750 | - | ||||
| 751 | - | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |||
| 752 | - | ||||
| 753 | - | late := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"}) | |||
| 754 | - | assert.True(t, late.next(t).Gap) | |||
| 755 | - | late.quiet(t, r, r.repo("open2", domain.VisibilityPublic)) | |||
| 756 | - | }) | |||
| 757 | - | } | |||
| 758 | - | ||||
| 759 | - | t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) { | |||
| 760 | - | r := newEventRig(t, 0) | |||
| 761 | - | n := r.repo("ghost", domain.VisibilityPublic) | |||
| 762 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |||
| 763 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | |||
| 764 | - | r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now} | |||
| 765 | - | root.quiet(t, r, n) | |||
| 766 | - | }) | |||
| 767 | } | 405 | } | ||
D internal/app/export_test.go +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package app | |||
| 4 | - | ||||
| 5 | - | import "bnema.dev/ermite/internal/domain" | |||
| 6 | - | ||||
| 7 | - | // Idle reports whether nothing is pending or running: tests wait for it | |||
| 8 | - | // instead of sleeping. | |||
| 9 | - | func (m *Maintainer) Idle() bool { | |||
| 10 | - | m.mu.Lock() | |||
| 11 | - | defer m.mu.Unlock() | |||
| 12 | - | return len(m.entries) == 0 | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | // AuthorOf exposes authorOf to the tests. | |||
| 16 | - | func AuthorOf(p domain.Principal) string { return authorOf(p) } | |||
R cmd/ermite/forge.go → internal/app/forge.go +28 -26 renamed, 86% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -24,11 +24,11 @@ import ( | |||||
| 24 | "bnema.dev/ermite/internal/adapters/rpc" | 24 | "bnema.dev/ermite/internal/adapters/rpc" | ||
| 25 | "bnema.dev/ermite/internal/adapters/sshsig" | 25 | "bnema.dev/ermite/internal/adapters/sshsig" | ||
| 26 | "bnema.dev/ermite/internal/adapters/web" | 26 | "bnema.dev/ermite/internal/adapters/web" | ||
| 27 | - | "bnema.dev/ermite/internal/app" | |||
| 28 | "bnema.dev/ermite/internal/clientlimit" | 27 | "bnema.dev/ermite/internal/clientlimit" | ||
| 29 | "bnema.dev/ermite/internal/config" | 28 | "bnema.dev/ermite/internal/config" | ||
| 30 | "bnema.dev/ermite/internal/domain" | 29 | "bnema.dev/ermite/internal/domain" | ||
| 31 | "bnema.dev/ermite/internal/ports" | 30 | "bnema.dev/ermite/internal/ports" | ||
| 31 | + | "bnema.dev/ermite/internal/usecase" | |||
| 32 | ) | 32 | ) | ||
| 33 | 33 | ||||
| 34 | // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in | 34 | // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in | ||
| @@ -40,13 +40,13 @@ type options struct { | |||||
| 40 | // hookExe is the executable the hook scripts exec; empty means this | 40 | // hookExe is the executable the hook scripts exec; empty means this | ||
| 41 | // process's own executable. | 41 | // process's own executable. | ||
| 42 | hookExe string | 42 | hookExe string | ||
| 43 | - | // maintenanceDelay overrides app.MaintenanceDelay when positive. | 43 | + | // maintenanceDelay overrides usecase.MaintenanceDelay when positive. |
| 44 | maintenanceDelay time.Duration | 44 | maintenanceDelay time.Duration | ||
| 45 | // maxBodyBytes overrides the limit of a push body when positive. | 45 | // maxBodyBytes overrides the limit of a push body when positive. | ||
| 46 | maxBodyBytes int64 | 46 | maxBodyBytes int64 | ||
| 47 | - | // eventBuffer overrides app.DefaultEventBuffer when positive. | 47 | + | // eventBuffer overrides usecase.DefaultEventBuffer when positive. |
| 48 | eventBuffer int | 48 | eventBuffer int | ||
| 49 | - | // revalidateEvery overrides app.DefaultRevalidateEvery when not zero. | 49 | + | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. |
| 50 | revalidateEvery time.Duration | 50 | revalidateEvery time.Duration | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| @@ -56,24 +56,24 @@ type forge struct { | |||||
| 56 | store *kvstore.Store | 56 | store *kvstore.Store | ||
| 57 | server *httpserver.Server | 57 | server *httpserver.Server | ||
| 58 | hooks *githttp.HookServer | 58 | hooks *githttp.HookServer | ||
| 59 | - | maint *app.Maintainer | 59 | + | maint *usecase.Maintainer |
| 60 | git *gitcli.Git | 60 | git *gitcli.Git | ||
| 61 | // reader is the ermite-git reader when ERMITE_GIT_READER selects it. | 61 | // reader is the ermite-git reader when ERMITE_GIT_READER selects it. | ||
| 62 | reader *ermitegit.Adapter | 62 | reader *ermitegit.Adapter | ||
| 63 | 63 | ||||
| 64 | // The use cases and the API are exposed for tests and for the phases | 64 | // The use cases and the API are exposed for tests and for the phases | ||
| 65 | // that add the web. | 65 | // that add the web. | ||
| 66 | - | Repos *app.Repos | 66 | + | Repos *usecase.Repos |
| 67 | - | Tokens *app.Tokens | 67 | + | Tokens *usecase.Tokens |
| 68 | - | Auth *app.Auth | 68 | + | Auth *usecase.Auth |
| 69 | - | Pushes *app.Pushes | 69 | + | Pushes *usecase.Pushes |
| 70 | - | Browse *app.Browse | 70 | + | Browse *usecase.Browse |
| 71 | // MergeRequests and SigningKey serve MergeRequestService and | 71 | // MergeRequests and SigningKey serve MergeRequestService and | ||
| 72 | // ServerService.GetSigningKey. | 72 | // ServerService.GetSigningKey. | ||
| 73 | - | MergeRequests *app.MergeRequests | 73 | + | MergeRequests *usecase.MergeRequests |
| 74 | - | SigningKey *app.SigningKey | 74 | + | SigningKey *usecase.SigningKey |
| 75 | - | Sessions *app.Sessions | 75 | + | Sessions *usecase.Sessions |
| 76 | - | EventLog *app.Events | 76 | + | EventLog *usecase.Events |
| 77 | API *rpc.API | 77 | API *rpc.API | ||
| 78 | Bus ports.EventBus | 78 | Bus ports.EventBus | ||
| 79 | } | 79 | } | ||
| @@ -139,7 +139,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 139 | 139 | ||||
| 140 | clock := systemClock{} | 140 | clock := systemClock{} | ||
| 141 | f.Bus = eventbus.New() | 141 | f.Bus = eventbus.New() | ||
| 142 | - | f.Repos = app.NewRepos(store, git, f.Bus, clock) | 142 | + | f.Repos = usecase.NewRepos(store, git, f.Bus, clock) |
| 143 | report, err := f.Repos.Reconcile(ctx) | 143 | report, err := f.Repos.Reconcile(ctx) | ||
| 144 | if err != nil { | 144 | if err != nil { | ||
| 145 | return nil, fmt.Errorf("reconcile repositories: %w", err) | 145 | return nil, fmt.Errorf("reconcile repositories: %w", err) | ||
| @@ -150,10 +150,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 150 | Int("invalid_dirs", len(report.InvalidDirs)). | 150 | Int("invalid_dirs", len(report.InvalidDirs)). | ||
| 151 | Msg("repositories reconciled") | 151 | Msg("repositories reconciled") | ||
| 152 | 152 | ||||
| 153 | - | f.Tokens = app.NewTokens(store.Tokens(), clock, store.Sessions()) | 153 | + | f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions()) |
| 154 | - | f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) | 154 | + | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) |
| 155 | - | f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay) | 155 | + | f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay) |
| 156 | - | f.Pushes = app.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) | 156 | + | f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) |
| 157 | 157 | ||||
| 158 | exe := opts.hookExe | 158 | exe := opts.hookExe | ||
| 159 | if exe == "" { | 159 | if exe == "" { | ||
| @@ -181,8 +181,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 181 | reader = f.reader | 181 | reader = f.reader | ||
| 182 | } | 182 | } | ||
| 183 | log.Info().Str("reader", cfg.GitReader).Msg("git reader selected") | 183 | log.Info().Str("reader", cfg.GitReader).Msg("git reader selected") | ||
| 184 | - | f.Browse = app.NewBrowse(store, reader) | 184 | + | f.Browse = usecase.NewBrowse(store, reader) |
| 185 | - | f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{ | 185 | + | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ |
| 186 | Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery, | 186 | Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery, | ||
| 187 | }) | 187 | }) | ||
| 188 | 188 | ||||
| @@ -196,14 +196,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 196 | return nil, err | 196 | return nil, err | ||
| 197 | } | 197 | } | ||
| 198 | committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail} | 198 | committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail} | ||
| 199 | - | f.SigningKey = app.NewSigningKey(signer, committer) | 199 | + | f.SigningKey = usecase.NewSigningKey(signer, committer) |
| 200 | - | f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{ | 200 | + | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ |
| 201 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | 201 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | ||
| 202 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | 202 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | ||
| 203 | }) | 203 | }) | ||
| 204 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | 204 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | ||
| 205 | 205 | ||||
| 206 | - | f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | 206 | + | f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) |
| 207 | if err != nil { | 207 | if err != nil { | ||
| 208 | return nil, err | 208 | return nil, err | ||
| 209 | } | 209 | } | ||
| @@ -240,13 +240,15 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 240 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | 240 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | ||
| 241 | } | 241 | } | ||
| 242 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | 242 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | ||
| 243 | - | site, err := newWeb(cfg, f.API, f.Sessions, limiter) | 243 | + | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) |
| 244 | + | site, err := newWeb(cfg, f, limiter) | |||
| 244 | if err != nil { | 245 | if err != nil { | ||
| 245 | return nil, fmt.Errorf("web: %w", err) | 246 | return nil, fmt.Errorf("web: %w", err) | ||
| 246 | } | 247 | } | ||
| 247 | routes = append(routes, webRoutes(site)...) | 248 | routes = append(routes, webRoutes(site)...) | ||
| 248 | f.server = httpserver.New(httpserver.Config{ | 249 | f.server = httpserver.New(httpserver.Config{ | ||
| 249 | Addr: cfg.Addr, | 250 | Addr: cfg.Addr, | ||
| 251 | + | PublicURL: cfg.PublicURL, | |||
| 250 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | 252 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | ||
| 251 | Routes: routes, | 253 | Routes: routes, | ||
| 252 | API: f.API.Handler(), | 254 | API: f.API.Handler(), | ||
R cmd/ermite/gitreader_test.go → internal/app/gitreader_test.go +3 -3 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os" | 6 | "os" | ||
| @@ -10,9 +10,9 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | - | "bnema.dev/ermite/internal/app" | |||
| 14 | "bnema.dev/ermite/internal/config" | 13 | "bnema.dev/ermite/internal/config" | ||
| 15 | "bnema.dev/ermite/internal/domain" | 14 | "bnema.dev/ermite/internal/domain" | ||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | // The suite runs with the reader of ERMITE_GIT_READER (ermite-git by default). | 18 | // The suite runs with the reader of ERMITE_GIT_READER (ermite-git by default). | ||
| @@ -28,7 +28,7 @@ func TestGitReaderOfTheForgeAndReadAfterPush(t *testing.T) { | |||||
| 28 | assert.Equal(t, want, h.forge.cfg.GitReader) | 28 | assert.Equal(t, want, h.forge.cfg.GitReader) | ||
| 29 | assert.Equal(t, want == config.GitReaderErmiteGit, h.forge.reader != nil, "the ermite-git adapter exists only for ermite-git") | 29 | assert.Equal(t, want == config.GitReaderErmiteGit, h.forge.reader != nil, "the ermite-git adapter exists only for ermite-git") | ||
| 30 | 30 | ||||
| 31 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | 31 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ |
| 32 | Name: h.name("fresh"), Visibility: domain.VisibilityPublic, | 32 | Name: h.name("fresh"), Visibility: domain.VisibilityPublic, | ||
| 33 | }) | 33 | }) | ||
| 34 | require.NoError(t, err) | 34 | require.NoError(t, err) | ||
R cmd/ermite/hook.go → internal/app/hook.go +1 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os" | 6 | "os" | ||
R cmd/ermite/main_test.go → internal/app/main_test.go +2 -2 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| @@ -43,7 +43,7 @@ func binary(t *testing.T) string { | |||||
| 43 | return | 43 | return | ||
| 44 | } | 44 | } | ||
| 45 | binPath = filepath.Join(dir, "ermite") | 45 | binPath = filepath.Join(dir, "ermite") | ||
| 46 | - | out, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput() | 46 | + | out, err := exec.Command("go", "build", "-o", binPath, "bnema.dev/ermite/cmd/ermite").CombinedOutput() |
| 47 | if err != nil { | 47 | if err != nil { | ||
| 48 | buildErr = errors.New("go build: " + err.Error() + "\n" + string(out)) | 48 | buildErr = errors.New("go build: " + err.Error() + "\n" + string(out)) | ||
| 49 | } | 49 | } | ||
D internal/app/maintenance_test.go +0 -187
| @@ -1,187 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package app_test | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | "runtime" | |||
| 9 | - | "sync" | |||
| 10 | - | "sync/atomic" | |||
| 11 | - | "testing" | |||
| 12 | - | "time" | |||
| 13 | - | ||||
| 14 | - | "github.com/stretchr/testify/assert" | |||
| 15 | - | ||||
| 16 | - | "bnema.dev/ermite/internal/app" | |||
| 17 | - | "bnema.dev/ermite/internal/domain" | |||
| 18 | - | "bnema.dev/ermite/internal/ports/mocks" | |||
| 19 | - | ) | |||
| 20 | - | ||||
| 21 | - | func waitFor(t *testing.T, cond func() bool, what string) { | |||
| 22 | - | t.Helper() | |||
| 23 | - | deadline := time.Now().Add(5 * time.Second) | |||
| 24 | - | for !cond() { | |||
| 25 | - | if time.Now().After(deadline) { | |||
| 26 | - | t.Fatalf("timeout waiting for %s", what) | |||
| 27 | - | } | |||
| 28 | - | time.Sleep(2 * time.Millisecond) | |||
| 29 | - | } | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | func TestMaintainerDebounces(t *testing.T) { | |||
| 33 | - | ctx := context.Background() | |||
| 34 | - | n := name(t, "tools") | |||
| 35 | - | git := mocks.NewGitRepos(t) | |||
| 36 | - | var runs atomic.Int32 | |||
| 37 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error { | |||
| 38 | - | runs.Add(1) | |||
| 39 | - | return nil | |||
| 40 | - | }) | |||
| 41 | - | m := app.NewMaintainer(ctx, git, 200*time.Millisecond) | |||
| 42 | - | defer m.Close(ctx) | |||
| 43 | - | ||||
| 44 | - | for range 10 { | |||
| 45 | - | m.Schedule(ctx, n) | |||
| 46 | - | } | |||
| 47 | - | assert.Equal(t, int32(0), runs.Load(), "waits for the delay") | |||
| 48 | - | waitFor(t, func() bool { return runs.Load() == 1 }, "one run") | |||
| 49 | - | // Idle: nothing is pending or running, so no second run is coming. | |||
| 50 | - | waitFor(t, m.Idle, "the maintainer to be idle") | |||
| 51 | - | assert.Equal(t, int32(1), runs.Load(), "a burst makes one run") | |||
| 52 | - | ||||
| 53 | - | m.Schedule(ctx, n) | |||
| 54 | - | waitFor(t, func() bool { return runs.Load() == 2 }, "a second burst, a second run") | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | func TestMaintainerOneRunAtATimePerRepo(t *testing.T) { | |||
| 58 | - | ctx := context.Background() | |||
| 59 | - | n, other := name(t, "tools"), name(t, "other") | |||
| 60 | - | git := mocks.NewGitRepos(t) | |||
| 61 | - | var concurrent, maxConcurrent, runs atomic.Int32 | |||
| 62 | - | release := make(chan struct{}) | |||
| 63 | - | var otherRan atomic.Bool | |||
| 64 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error { | |||
| 65 | - | c := concurrent.Add(1) | |||
| 66 | - | for { | |||
| 67 | - | m := maxConcurrent.Load() | |||
| 68 | - | if c <= m || maxConcurrent.CompareAndSwap(m, c) { | |||
| 69 | - | break | |||
| 70 | - | } | |||
| 71 | - | } | |||
| 72 | - | runs.Add(1) | |||
| 73 | - | <-release | |||
| 74 | - | concurrent.Add(-1) | |||
| 75 | - | return nil | |||
| 76 | - | }) | |||
| 77 | - | git.EXPECT().Maintain(mockAny, other).RunAndReturn(func(context.Context, domain.RepoName) error { | |||
| 78 | - | otherRan.Store(true) | |||
| 79 | - | return nil | |||
| 80 | - | }) | |||
| 81 | - | m := app.NewMaintainer(ctx, git, 10*time.Millisecond) | |||
| 82 | - | defer m.Close(ctx) | |||
| 83 | - | ||||
| 84 | - | m.Schedule(ctx, n) | |||
| 85 | - | waitFor(t, func() bool { return runs.Load() == 1 }, "first run to start") | |||
| 86 | - | m.Schedule(ctx, n) // during the run: queues exactly one more | |||
| 87 | - | m.Schedule(ctx, n) | |||
| 88 | - | m.Schedule(ctx, other) | |||
| 89 | - | waitFor(t, otherRan.Load, "another repository is not blocked") | |||
| 90 | - | assert.Equal(t, int32(1), runs.Load(), "the second run waits for the first") | |||
| 91 | - | ||||
| 92 | - | release <- struct{}{} // finish first run | |||
| 93 | - | waitFor(t, func() bool { return runs.Load() == 2 }, "queued run") | |||
| 94 | - | release <- struct{}{} | |||
| 95 | - | waitFor(t, m.Idle, "the maintainer to be idle") | |||
| 96 | - | assert.Equal(t, int32(2), runs.Load(), "exactly one queued run") | |||
| 97 | - | assert.Equal(t, int32(1), maxConcurrent.Load()) | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | func TestMaintainerTellsTheReaderAfterARun(t *testing.T) { | |||
| 101 | - | ctx := context.Background() | |||
| 102 | - | n := name(t, "tools") | |||
| 103 | - | for _, failure := range []error{nil, errors.New("boom")} { | |||
| 104 | - | git := mocks.NewGitRepos(t) | |||
| 105 | - | git.EXPECT().Maintain(mockAny, n).Return(failure) | |||
| 106 | - | fresh := &freshener{} | |||
| 107 | - | m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh) | |||
| 108 | - | m.Schedule(ctx, n) | |||
| 109 | - | waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told") | |||
| 110 | - | m.Close(ctx) | |||
| 111 | - | assert.Equal(t, []domain.RepoName{n}, fresh.seen()) | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) { | |||
| 116 | - | ctx := context.Background() | |||
| 117 | - | n := name(t, "tools") | |||
| 118 | - | git := mocks.NewGitRepos(t) | |||
| 119 | - | var runs atomic.Int32 | |||
| 120 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error { | |||
| 121 | - | runs.Add(1) | |||
| 122 | - | return errors.New("boom") | |||
| 123 | - | }) | |||
| 124 | - | m := app.NewMaintainer(ctx, git, 5*time.Millisecond) | |||
| 125 | - | defer m.Close(ctx) | |||
| 126 | - | m.Schedule(ctx, n) | |||
| 127 | - | waitFor(t, func() bool { return runs.Load() == 1 }, "run") | |||
| 128 | - | m.Schedule(ctx, n) | |||
| 129 | - | waitFor(t, func() bool { return runs.Load() == 2 }, "later runs still happen") | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | func TestMaintainerClose(t *testing.T) { | |||
| 133 | - | ctx := context.Background() | |||
| 134 | - | n := name(t, "tools") | |||
| 135 | - | ||||
| 136 | - | t.Run("pending runs are dropped", func(t *testing.T) { | |||
| 137 | - | git := mocks.NewGitRepos(t) // no Maintain expected | |||
| 138 | - | m := app.NewMaintainer(ctx, git, time.Hour) | |||
| 139 | - | m.Schedule(ctx, n) | |||
| 140 | - | m.Close(ctx) | |||
| 141 | - | m.Schedule(ctx, n) // after Close: ignored | |||
| 142 | - | m.Close(ctx) // twice is fine | |||
| 143 | - | }) | |||
| 144 | - | ||||
| 145 | - | t.Run("a run in progress is cancelled and awaited", func(t *testing.T) { | |||
| 146 | - | git := mocks.NewGitRepos(t) | |||
| 147 | - | started := make(chan struct{}) | |||
| 148 | - | var cancelled atomic.Bool | |||
| 149 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(ctx context.Context, _ domain.RepoName) error { | |||
| 150 | - | close(started) | |||
| 151 | - | <-ctx.Done() | |||
| 152 | - | cancelled.Store(true) | |||
| 153 | - | return ctx.Err() | |||
| 154 | - | }) | |||
| 155 | - | m := app.NewMaintainer(ctx, git, time.Millisecond) | |||
| 156 | - | m.Schedule(ctx, n) | |||
| 157 | - | <-started | |||
| 158 | - | done := make(chan struct{}) | |||
| 159 | - | go func() { m.Close(ctx); close(done) }() | |||
| 160 | - | select { | |||
| 161 | - | case <-done: | |||
| 162 | - | case <-time.After(5 * time.Second): | |||
| 163 | - | t.Fatal("Close did not return") | |||
| 164 | - | } | |||
| 165 | - | assert.True(t, cancelled.Load()) | |||
| 166 | - | }) | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | func TestMaintainerConcurrentSchedule(t *testing.T) { | |||
| 170 | - | ctx := context.Background() | |||
| 171 | - | git := mocks.NewGitRepos(t) | |||
| 172 | - | git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe() | |||
| 173 | - | m := app.NewMaintainer(ctx, git, time.Millisecond) | |||
| 174 | - | var wg sync.WaitGroup | |||
| 175 | - | for i := range 8 { | |||
| 176 | - | wg.Add(1) | |||
| 177 | - | go func() { | |||
| 178 | - | defer wg.Done() | |||
| 179 | - | for range 50 { | |||
| 180 | - | m.Schedule(ctx, name(t, string(rune('a'+i%3)))) | |||
| 181 | - | runtime.Gosched() | |||
| 182 | - | } | |||
| 183 | - | }() | |||
| 184 | - | } | |||
| 185 | - | wg.Wait() | |||
| 186 | - | m.Close(ctx) | |||
| 187 | - | } | |||
R cmd/ermite/mr_api_test.go → internal/app/mr_api_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os" | 6 | "os" | ||
R cmd/ermite/root.go → internal/app/root.go +2 -2 renamed, 93% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "github.com/spf13/cobra" | 6 | "github.com/spf13/cobra" | ||
| @@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 21 | "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" + | 21 | "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" + | ||
| 22 | "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.", | 22 | "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.", | ||
| 23 | SilenceUsage: true, | 23 | SilenceUsage: true, | ||
| 24 | - | SilenceErrors: true, // main prints the error once | 24 | + | SilenceErrors: true, // cmd/ermite prints the error once |
| 25 | } | 25 | } | ||
| 26 | root.AddCommand( | 26 | root.AddCommand( | ||
| 27 | newServeCmd(), newHookCmd(), | 27 | newServeCmd(), newHookCmd(), | ||
R cmd/ermite/serve.go → internal/app/serve.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
R cmd/ermite/testdata/web/blob-binary.html → internal/app/testdata/web/blob-binary.html +28 -73 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -230,7 +231,7 @@ | |||||
| 230 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 231 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 231 | <div class="panel-head"> | 232 | <div class="panel-head"> | ||
| 232 | <label for="refs-filter" class="sr">filter branches and tags</label> | 233 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 233 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 234 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 234 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 235 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 235 | <kbd>esc</kbd> | 236 | <kbd>esc</kbd> | ||
| 236 | </button> | 237 | </button> | ||
| @@ -265,13 +266,7 @@ | |||||
| 265 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 266 | <div class="panel-head"> | 267 | <div class="panel-head"> | ||
| 267 | <label for="cmd-q" class="sr">go to</label> | 268 | <label for="cmd-q" class="sr">go to</label> | ||
| 268 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 269 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 270 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 271 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 272 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 273 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 274 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 275 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 276 | <kbd>esc</kbd> | 271 | <kbd>esc</kbd> | ||
| 277 | </button> | 272 | </button> | ||
| @@ -342,67 +337,27 @@ | |||||
| 342 | <kbd>esc</kbd> close</span> | 337 | <kbd>esc</kbd> close</span> | ||
| 343 | </p> | 338 | </p> | ||
| 344 | </dialog> | 339 | </dialog> | ||
| 345 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 340 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 346 | - | if (q('dialog[open]').count) return; | 341 | + | k-k from:body -> ermite.move(-1); |
| 347 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 342 | + | k-top from:body -> ermite.edge(false); |
| 348 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 343 | + | k-bottom from:body -> ermite.edge(true); |
| 349 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 344 | + | k-open from:body -> ermite.open(); |
| 350 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 345 | + | k-esc from:body -> ermite.closeDialogs(); |
| 351 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 346 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 352 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 347 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 353 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 348 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 354 | - | })(1); | 349 | + | k-filter from:body -> ermite.focusFilter(); |
| 355 | - | k-k from:body -> (async (d) => { | 350 | + | k-gh from:body -> ermite.go('.brand'); |
| 356 | - | if (q('dialog[open]').count) return; | 351 | + | k-s from:body -> ermite.tab('s'); |
| 357 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 352 | + | k-t from:body -> ermite.tab('t'); |
| 358 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 353 | + | k-c from:body -> ermite.tab('c'); |
| 359 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 354 | + | k-r from:body -> ermite.tab('r'); |
| 360 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 355 | + | k-m from:body -> ermite.tab('m'); |
| 361 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 356 | + | k-next from:body -> ermite.nextNote(); |
| 362 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 357 | + | k-split from:body -> ermite.layout('split'); |
| 363 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 358 | + | k-unified from:body -> ermite.layout('unified'); |
| 364 | - | })(-1); | 359 | + | k-collapse from:body -> ermite.collapse(); |
| 365 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 360 | + | k-permalink from:body -> ermite.permalink()"> |
| 366 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 367 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 368 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 369 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 370 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 371 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 372 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 373 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 374 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 375 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 376 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 377 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 378 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 379 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 380 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 381 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 382 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 383 | - | k-next from:body -> { | |||
| 384 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 385 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 386 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 387 | - | }; | |||
| 388 | - | k-split from:body -> { | |||
| 389 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 390 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 391 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 392 | - | }; | |||
| 393 | - | k-unified from:body -> { | |||
| 394 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 395 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 396 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 397 | - | }; | |||
| 398 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 399 | - | k-permalink from:body -> { | |||
| 400 | - | let b = document.body.dataset; | |||
| 401 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 402 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 403 | - | navigator.clipboard?.writeText(location.href); | |||
| 404 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 405 | - | }"> | |||
| 406 | </div> | 361 | </div> | ||
| 407 | </body> | 362 | </body> | ||
| 408 | </html> | 363 | </html> | ||
R cmd/ermite/testdata/web/blob-code.html → internal/app/testdata/web/blob-code.html +29 -74 renamed, 62% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -102,7 +103,7 @@ | |||||
| 102 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | ||
| 103 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | ||
| 104 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | ||
| 105 | - | <code> | 106 | + | <code hx-ignore> |
| 106 | <span class="line" id="L1"> | 107 | <span class="line" id="L1"> | ||
| 107 | <a class="ln" href="#L1">1</a> | 108 | <a class="ln" href="#L1">1</a> | ||
| 108 | <span class="kn">package</span> | 109 | <span class="kn">package</span> | ||
| @@ -300,7 +301,7 @@ | |||||
| 300 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 301 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 301 | <div class="panel-head"> | 302 | <div class="panel-head"> | ||
| 302 | <label for="refs-filter" class="sr">filter branches and tags</label> | 303 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 303 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 304 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 304 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 305 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 305 | <kbd>esc</kbd> | 306 | <kbd>esc</kbd> | ||
| 306 | </button> | 307 | </button> | ||
| @@ -335,13 +336,7 @@ | |||||
| 335 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 336 | <div class="panel-head"> | 337 | <div class="panel-head"> | ||
| 337 | <label for="cmd-q" class="sr">go to</label> | 338 | <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="let t = this.value.toLowerCase().trim(); | 339 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> |
| 339 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 340 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 341 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 342 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 343 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 344 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 345 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 340 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 346 | <kbd>esc</kbd> | 341 | <kbd>esc</kbd> | ||
| 347 | </button> | 342 | </button> | ||
| @@ -412,67 +407,27 @@ | |||||
| 412 | <kbd>esc</kbd> close</span> | 407 | <kbd>esc</kbd> close</span> | ||
| 413 | </p> | 408 | </p> | ||
| 414 | </dialog> | 409 | </dialog> | ||
| 415 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 410 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 416 | - | if (q('dialog[open]').count) return; | 411 | + | k-k from:body -> ermite.move(-1); |
| 417 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 412 | + | k-top from:body -> ermite.edge(false); |
| 418 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 413 | + | k-bottom from:body -> ermite.edge(true); |
| 419 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 414 | + | k-open from:body -> ermite.open(); |
| 420 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 415 | + | k-esc from:body -> ermite.closeDialogs(); |
| 421 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 416 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 422 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 417 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 423 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 418 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 424 | - | })(1); | 419 | + | k-filter from:body -> ermite.focusFilter(); |
| 425 | - | k-k from:body -> (async (d) => { | 420 | + | k-gh from:body -> ermite.go('.brand'); |
| 426 | - | if (q('dialog[open]').count) return; | 421 | + | k-s from:body -> ermite.tab('s'); |
| 427 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 422 | + | k-t from:body -> ermite.tab('t'); |
| 428 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 423 | + | k-c from:body -> ermite.tab('c'); |
| 429 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 424 | + | k-r from:body -> ermite.tab('r'); |
| 430 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 425 | + | k-m from:body -> ermite.tab('m'); |
| 431 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 426 | + | k-next from:body -> ermite.nextNote(); |
| 432 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 427 | + | k-split from:body -> ermite.layout('split'); |
| 433 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 428 | + | k-unified from:body -> ermite.layout('unified'); |
| 434 | - | })(-1); | 429 | + | k-collapse from:body -> ermite.collapse(); |
| 435 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 430 | + | k-permalink from:body -> ermite.permalink()"> |
| 436 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 437 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 438 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 439 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 440 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 441 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 442 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 443 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 444 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 445 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 446 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 447 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 448 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 449 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 450 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 451 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 452 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 453 | - | k-next from:body -> { | |||
| 454 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 455 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 456 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 457 | - | }; | |||
| 458 | - | k-split from:body -> { | |||
| 459 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 460 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 461 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 462 | - | }; | |||
| 463 | - | k-unified from:body -> { | |||
| 464 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 465 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 466 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 467 | - | }; | |||
| 468 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 469 | - | k-permalink from:body -> { | |||
| 470 | - | let b = document.body.dataset; | |||
| 471 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 472 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 473 | - | navigator.clipboard?.writeText(location.href); | |||
| 474 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 475 | - | }"> | |||
| 476 | </div> | 431 | </div> | ||
| 477 | </body> | 432 | </body> | ||
| 478 | </html> | 433 | </html> | ||
R cmd/ermite/testdata/web/blob-empty.html → internal/app/testdata/web/blob-empty.html +28 -73 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -230,7 +231,7 @@ | |||||
| 230 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 231 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 231 | <div class="panel-head"> | 232 | <div class="panel-head"> | ||
| 232 | <label for="refs-filter" class="sr">filter branches and tags</label> | 233 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 233 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 234 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 234 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 235 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 235 | <kbd>esc</kbd> | 236 | <kbd>esc</kbd> | ||
| 236 | </button> | 237 | </button> | ||
| @@ -265,13 +266,7 @@ | |||||
| 265 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 266 | <div class="panel-head"> | 267 | <div class="panel-head"> | ||
| 267 | <label for="cmd-q" class="sr">go to</label> | 268 | <label for="cmd-q" class="sr">go to</label> | ||
| 268 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 269 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 270 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 271 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 272 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 273 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 274 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 275 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 276 | <kbd>esc</kbd> | 271 | <kbd>esc</kbd> | ||
| 277 | </button> | 272 | </button> | ||
| @@ -342,67 +337,27 @@ | |||||
| 342 | <kbd>esc</kbd> close</span> | 337 | <kbd>esc</kbd> close</span> | ||
| 343 | </p> | 338 | </p> | ||
| 344 | </dialog> | 339 | </dialog> | ||
| 345 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 340 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 346 | - | if (q('dialog[open]').count) return; | 341 | + | k-k from:body -> ermite.move(-1); |
| 347 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 342 | + | k-top from:body -> ermite.edge(false); |
| 348 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 343 | + | k-bottom from:body -> ermite.edge(true); |
| 349 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 344 | + | k-open from:body -> ermite.open(); |
| 350 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 345 | + | k-esc from:body -> ermite.closeDialogs(); |
| 351 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 346 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 352 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 347 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 353 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 348 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 354 | - | })(1); | 349 | + | k-filter from:body -> ermite.focusFilter(); |
| 355 | - | k-k from:body -> (async (d) => { | 350 | + | k-gh from:body -> ermite.go('.brand'); |
| 356 | - | if (q('dialog[open]').count) return; | 351 | + | k-s from:body -> ermite.tab('s'); |
| 357 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 352 | + | k-t from:body -> ermite.tab('t'); |
| 358 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 353 | + | k-c from:body -> ermite.tab('c'); |
| 359 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 354 | + | k-r from:body -> ermite.tab('r'); |
| 360 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 355 | + | k-m from:body -> ermite.tab('m'); |
| 361 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 356 | + | k-next from:body -> ermite.nextNote(); |
| 362 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 357 | + | k-split from:body -> ermite.layout('split'); |
| 363 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 358 | + | k-unified from:body -> ermite.layout('unified'); |
| 364 | - | })(-1); | 359 | + | k-collapse from:body -> ermite.collapse(); |
| 365 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 360 | + | k-permalink from:body -> ermite.permalink()"> |
| 366 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 367 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 368 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 369 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 370 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 371 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 372 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 373 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 374 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 375 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 376 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 377 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 378 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 379 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 380 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 381 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 382 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 383 | - | k-next from:body -> { | |||
| 384 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 385 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 386 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 387 | - | }; | |||
| 388 | - | k-split from:body -> { | |||
| 389 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 390 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 391 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 392 | - | }; | |||
| 393 | - | k-unified from:body -> { | |||
| 394 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 395 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 396 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 397 | - | }; | |||
| 398 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 399 | - | k-permalink from:body -> { | |||
| 400 | - | let b = document.body.dataset; | |||
| 401 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 402 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 403 | - | navigator.clipboard?.writeText(location.href); | |||
| 404 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 405 | - | }"> | |||
| 406 | </div> | 361 | </div> | ||
| 407 | </body> | 362 | </body> | ||
| 408 | </html> | 363 | </html> | ||
R cmd/ermite/testdata/web/blob-image.html → internal/app/testdata/web/blob-image.html +28 -73 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -232,7 +233,7 @@ | |||||
| 232 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 233 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 233 | <div class="panel-head"> | 234 | <div class="panel-head"> | ||
| 234 | <label for="refs-filter" class="sr">filter branches and tags</label> | 235 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 235 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 236 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 236 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 237 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 237 | <kbd>esc</kbd> | 238 | <kbd>esc</kbd> | ||
| 238 | </button> | 239 | </button> | ||
| @@ -267,13 +268,7 @@ | |||||
| 267 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 268 | <div class="panel-head"> | 269 | <div class="panel-head"> | ||
| 269 | <label for="cmd-q" class="sr">go to</label> | 270 | <label for="cmd-q" class="sr">go to</label> | ||
| 270 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 271 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 272 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 273 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 274 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 275 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 276 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 277 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 272 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 278 | <kbd>esc</kbd> | 273 | <kbd>esc</kbd> | ||
| 279 | </button> | 274 | </button> | ||
| @@ -344,67 +339,27 @@ | |||||
| 344 | <kbd>esc</kbd> close</span> | 339 | <kbd>esc</kbd> close</span> | ||
| 345 | </p> | 340 | </p> | ||
| 346 | </dialog> | 341 | </dialog> | ||
| 347 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 342 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 348 | - | if (q('dialog[open]').count) return; | 343 | + | k-k from:body -> ermite.move(-1); |
| 349 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 344 | + | k-top from:body -> ermite.edge(false); |
| 350 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 345 | + | k-bottom from:body -> ermite.edge(true); |
| 351 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 346 | + | k-open from:body -> ermite.open(); |
| 352 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 347 | + | k-esc from:body -> ermite.closeDialogs(); |
| 353 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 348 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 354 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 349 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 355 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 350 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 356 | - | })(1); | 351 | + | k-filter from:body -> ermite.focusFilter(); |
| 357 | - | k-k from:body -> (async (d) => { | 352 | + | k-gh from:body -> ermite.go('.brand'); |
| 358 | - | if (q('dialog[open]').count) return; | 353 | + | k-s from:body -> ermite.tab('s'); |
| 359 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 354 | + | k-t from:body -> ermite.tab('t'); |
| 360 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 355 | + | k-c from:body -> ermite.tab('c'); |
| 361 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 356 | + | k-r from:body -> ermite.tab('r'); |
| 362 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 357 | + | k-m from:body -> ermite.tab('m'); |
| 363 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 358 | + | k-next from:body -> ermite.nextNote(); |
| 364 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 359 | + | k-split from:body -> ermite.layout('split'); |
| 365 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 360 | + | k-unified from:body -> ermite.layout('unified'); |
| 366 | - | })(-1); | 361 | + | k-collapse from:body -> ermite.collapse(); |
| 367 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 362 | + | k-permalink from:body -> ermite.permalink()"> |
| 368 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 369 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 370 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 371 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 372 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 373 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 374 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 375 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 376 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 377 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 378 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 379 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 380 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 381 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 382 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 383 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 384 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 385 | - | k-next from:body -> { | |||
| 386 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 387 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 388 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 389 | - | }; | |||
| 390 | - | k-split from:body -> { | |||
| 391 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 392 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 393 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 394 | - | }; | |||
| 395 | - | k-unified from:body -> { | |||
| 396 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 397 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 398 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 399 | - | }; | |||
| 400 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 401 | - | k-permalink from:body -> { | |||
| 402 | - | let b = document.body.dataset; | |||
| 403 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 404 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 405 | - | navigator.clipboard?.writeText(location.href); | |||
| 406 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 407 | - | }"> | |||
| 408 | </div> | 363 | </div> | ||
| 409 | </body> | 364 | </body> | ||
| 410 | </html> | 365 | </html> | ||
R cmd/ermite/testdata/web/blob-markdown.html → internal/app/testdata/web/blob-markdown.html +30 -75 renamed, 62% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -48,19 +49,19 @@ | |||||
| 48 | </ol> | 49 | </ol> | ||
| 49 | </nav> | 50 | </nav> | ||
| 50 | <div class="hdr-acts"> | 51 | <div class="hdr-acts"> | ||
| 51 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 52 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 52 | <span class="ico i-branch" aria-hidden="true"> | 53 | <span class="ico i-branch" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="ref-name">main</span> | 55 | <span class="ref-name">main</span> | ||
| 55 | <span class="sha">SHA7</span> | 56 | <span class="sha">SHA7</span> | ||
| 56 | - | </button> | 57 | + | </a> |
| 57 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 58 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 58 | <span class="ico i-search" aria-hidden="true"> | 59 | <span class="ico i-search" aria-hidden="true"> | ||
| 59 | </span> | 60 | </span> | ||
| 60 | <span class="search-label">Go to…</span> | 61 | <span class="search-label">Go to…</span> | ||
| 61 | <kbd aria-hidden="true">ctrl k</kbd> | 62 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 62 | </button> | 63 | </button> | ||
| 63 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 64 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 64 | </div> | 65 | </div> | ||
| 65 | </div> | 66 | </div> | ||
| 66 | <nav class="tabs" aria-label="Sections"> | 67 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -98,7 +99,7 @@ | |||||
| 98 | <a class="btn" href="/files/tree/main/docs">Folder</a> | 99 | <a class="btn" href="/files/tree/main/docs">Folder</a> | ||
| 99 | </span> | 100 | </span> | ||
| 100 | </h1> | 101 | </h1> | ||
| 101 | - | <article class="prose" aria-label="guide.md"> | 102 | + | <article class="prose" aria-label="guide.md" hx-ignore> |
| 102 | <h1 id="md-guide">Guide</h1> | 103 | <h1 id="md-guide">Guide</h1> | ||
| 103 | <h2 id="md-steps">Steps</h2> | 104 | <h2 id="md-steps">Steps</h2> | ||
| 104 | <p>Run it.</p> | 105 | <p>Run it.</p> | ||
| @@ -116,7 +117,7 @@ | |||||
| 116 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | 117 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | ||
| 117 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | 118 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | ||
| 118 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | 119 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | ||
| 119 | - | <code> | 120 | + | <code hx-ignore> |
| 120 | <span class="line" id="L1"> | 121 | <span class="line" id="L1"> | ||
| 121 | <a class="ln" href="#L1">1</a> | 122 | <a class="ln" href="#L1">1</a> | ||
| 122 | <span class="gh"># Guide</span> | 123 | <span class="gh"># Guide</span> | ||
| @@ -297,7 +298,7 @@ | |||||
| 297 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 298 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 298 | <div class="panel-head"> | 299 | <div class="panel-head"> | ||
| 299 | <label for="refs-filter" class="sr">filter branches and tags</label> | 300 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 300 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 301 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 301 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 302 | <kbd>esc</kbd> | 303 | <kbd>esc</kbd> | ||
| 303 | </button> | 304 | </button> | ||
| @@ -332,13 +333,7 @@ | |||||
| 332 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 333 | <div class="panel-head"> | 334 | <div class="panel-head"> | ||
| 334 | <label for="cmd-q" class="sr">go to</label> | 335 | <label for="cmd-q" class="sr">go to</label> | ||
| 335 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 336 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 337 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 338 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 339 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 340 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 341 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 342 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 337 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 343 | <kbd>esc</kbd> | 338 | <kbd>esc</kbd> | ||
| 344 | </button> | 339 | </button> | ||
| @@ -409,67 +404,27 @@ | |||||
| 409 | <kbd>esc</kbd> close</span> | 404 | <kbd>esc</kbd> close</span> | ||
| 410 | </p> | 405 | </p> | ||
| 411 | </dialog> | 406 | </dialog> | ||
| 412 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 407 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 413 | - | if (q('dialog[open]').count) return; | 408 | + | k-k from:body -> ermite.move(-1); |
| 414 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 409 | + | k-top from:body -> ermite.edge(false); |
| 415 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 410 | + | k-bottom from:body -> ermite.edge(true); |
| 416 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 411 | + | k-open from:body -> ermite.open(); |
| 417 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 412 | + | k-esc from:body -> ermite.closeDialogs(); |
| 418 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 413 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 419 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 414 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 420 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 415 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 421 | - | })(1); | 416 | + | k-filter from:body -> ermite.focusFilter(); |
| 422 | - | k-k from:body -> (async (d) => { | 417 | + | k-gh from:body -> ermite.go('.brand'); |
| 423 | - | if (q('dialog[open]').count) return; | 418 | + | k-s from:body -> ermite.tab('s'); |
| 424 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 419 | + | k-t from:body -> ermite.tab('t'); |
| 425 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 420 | + | k-c from:body -> ermite.tab('c'); |
| 426 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 421 | + | k-r from:body -> ermite.tab('r'); |
| 427 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 422 | + | k-m from:body -> ermite.tab('m'); |
| 428 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 423 | + | k-next from:body -> ermite.nextNote(); |
| 429 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 424 | + | k-split from:body -> ermite.layout('split'); |
| 430 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 425 | + | k-unified from:body -> ermite.layout('unified'); |
| 431 | - | })(-1); | 426 | + | k-collapse from:body -> ermite.collapse(); |
| 432 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 427 | + | k-permalink from:body -> ermite.permalink()"> |
| 433 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 434 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 435 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 436 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 437 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 438 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 439 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 440 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 441 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 442 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 443 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 444 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 445 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 446 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 447 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 448 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 449 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 450 | - | k-next from:body -> { | |||
| 451 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 452 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 453 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 454 | - | }; | |||
| 455 | - | k-split from:body -> { | |||
| 456 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 457 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 458 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 459 | - | }; | |||
| 460 | - | k-unified from:body -> { | |||
| 461 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 462 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 463 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 464 | - | }; | |||
| 465 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 466 | - | k-permalink from:body -> { | |||
| 467 | - | let b = document.body.dataset; | |||
| 468 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 469 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 470 | - | navigator.clipboard?.writeText(location.href); | |||
| 471 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 472 | - | }"> | |||
| 473 | </div> | 428 | </div> | ||
| 474 | </body> | 429 | </body> | ||
| 475 | </html> | 430 | </html> | ||
R cmd/ermite/testdata/web/blob-svg.html → internal/app/testdata/web/blob-svg.html +29 -74 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -102,7 +103,7 @@ | |||||
| 102 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | ||
| 103 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | ||
| 104 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | ||
| 105 | - | <code> | 106 | + | <code hx-ignore> |
| 106 | <span class="line" id="L1"> | 107 | <span class="line" id="L1"> | ||
| 107 | <a class="ln" href="#L1">1</a> | 108 | <a class="ln" href="#L1">1</a> | ||
| 108 | <span class="nt"><svg</span> | 109 | <span class="nt"><svg</span> | ||
| @@ -255,7 +256,7 @@ | |||||
| 255 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 256 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 256 | <div class="panel-head"> | 257 | <div class="panel-head"> | ||
| 257 | <label for="refs-filter" class="sr">filter branches and tags</label> | 258 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 258 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 259 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 259 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 260 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 260 | <kbd>esc</kbd> | 261 | <kbd>esc</kbd> | ||
| 261 | </button> | 262 | </button> | ||
| @@ -290,13 +291,7 @@ | |||||
| 290 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 291 | <div class="panel-head"> | 292 | <div class="panel-head"> | ||
| 292 | <label for="cmd-q" class="sr">go to</label> | 293 | <label for="cmd-q" class="sr">go to</label> | ||
| 293 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 294 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 295 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 296 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 297 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 298 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 299 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 300 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 295 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 301 | <kbd>esc</kbd> | 296 | <kbd>esc</kbd> | ||
| 302 | </button> | 297 | </button> | ||
| @@ -367,67 +362,27 @@ | |||||
| 367 | <kbd>esc</kbd> close</span> | 362 | <kbd>esc</kbd> close</span> | ||
| 368 | </p> | 363 | </p> | ||
| 369 | </dialog> | 364 | </dialog> | ||
| 370 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 365 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 371 | - | if (q('dialog[open]').count) return; | 366 | + | k-k from:body -> ermite.move(-1); |
| 372 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 367 | + | k-top from:body -> ermite.edge(false); |
| 373 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 368 | + | k-bottom from:body -> ermite.edge(true); |
| 374 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 369 | + | k-open from:body -> ermite.open(); |
| 375 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 370 | + | k-esc from:body -> ermite.closeDialogs(); |
| 376 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 371 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 377 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 372 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 378 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 373 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 379 | - | })(1); | 374 | + | k-filter from:body -> ermite.focusFilter(); |
| 380 | - | k-k from:body -> (async (d) => { | 375 | + | k-gh from:body -> ermite.go('.brand'); |
| 381 | - | if (q('dialog[open]').count) return; | 376 | + | k-s from:body -> ermite.tab('s'); |
| 382 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 377 | + | k-t from:body -> ermite.tab('t'); |
| 383 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 378 | + | k-c from:body -> ermite.tab('c'); |
| 384 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 379 | + | k-r from:body -> ermite.tab('r'); |
| 385 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 380 | + | k-m from:body -> ermite.tab('m'); |
| 386 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 381 | + | k-next from:body -> ermite.nextNote(); |
| 387 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 382 | + | k-split from:body -> ermite.layout('split'); |
| 388 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 383 | + | k-unified from:body -> ermite.layout('unified'); |
| 389 | - | })(-1); | 384 | + | k-collapse from:body -> ermite.collapse(); |
| 390 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 385 | + | k-permalink from:body -> ermite.permalink()"> |
| 391 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 392 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 393 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 394 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 395 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 396 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 397 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 398 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 399 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 400 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 401 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 402 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 403 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 404 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 405 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 406 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 407 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 408 | - | k-next from:body -> { | |||
| 409 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 410 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 411 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 412 | - | }; | |||
| 413 | - | k-split from:body -> { | |||
| 414 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 415 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 416 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 417 | - | }; | |||
| 418 | - | k-unified from:body -> { | |||
| 419 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 420 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 421 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 422 | - | }; | |||
| 423 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 424 | - | k-permalink from:body -> { | |||
| 425 | - | let b = document.body.dataset; | |||
| 426 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 427 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 428 | - | navigator.clipboard?.writeText(location.href); | |||
| 429 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 430 | - | }"> | |||
| 431 | </div> | 386 | </div> | ||
| 432 | </body> | 387 | </body> | ||
| 433 | </html> | 388 | </html> | ||
R cmd/ermite/testdata/web/blob-text.html → internal/app/testdata/web/blob-text.html +29 -74 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -102,7 +103,7 @@ | |||||
| 102 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | ||
| 103 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | ||
| 104 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | ||
| 105 | - | <code> | 106 | + | <code hx-ignore> |
| 106 | <span class="line" id="L1"> | 107 | <span class="line" id="L1"> | ||
| 107 | <a class="ln" href="#L1">1</a>plain notes</span> | 108 | <a class="ln" href="#L1">1</a>plain notes</span> | ||
| 108 | <span class="line" id="L2"> | 109 | <span class="line" id="L2"> | ||
| @@ -250,7 +251,7 @@ | |||||
| 250 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 251 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 251 | <div class="panel-head"> | 252 | <div class="panel-head"> | ||
| 252 | <label for="refs-filter" class="sr">filter branches and tags</label> | 253 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 253 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 254 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 254 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 255 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 255 | <kbd>esc</kbd> | 256 | <kbd>esc</kbd> | ||
| 256 | </button> | 257 | </button> | ||
| @@ -285,13 +286,7 @@ | |||||
| 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()"> | 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()"> | ||
| 286 | <div class="panel-head"> | 287 | <div class="panel-head"> | ||
| 287 | <label for="cmd-q" class="sr">go to</label> | 288 | <label for="cmd-q" class="sr">go to</label> | ||
| 288 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 289 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 290 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 291 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 292 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 293 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 294 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 295 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 290 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 296 | <kbd>esc</kbd> | 291 | <kbd>esc</kbd> | ||
| 297 | </button> | 292 | </button> | ||
| @@ -362,67 +357,27 @@ | |||||
| 362 | <kbd>esc</kbd> close</span> | 357 | <kbd>esc</kbd> close</span> | ||
| 363 | </p> | 358 | </p> | ||
| 364 | </dialog> | 359 | </dialog> | ||
| 365 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 360 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 366 | - | if (q('dialog[open]').count) return; | 361 | + | k-k from:body -> ermite.move(-1); |
| 367 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 362 | + | k-top from:body -> ermite.edge(false); |
| 368 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 363 | + | k-bottom from:body -> ermite.edge(true); |
| 369 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 364 | + | k-open from:body -> ermite.open(); |
| 370 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 365 | + | k-esc from:body -> ermite.closeDialogs(); |
| 371 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 366 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 372 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 367 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 373 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 368 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 374 | - | })(1); | 369 | + | k-filter from:body -> ermite.focusFilter(); |
| 375 | - | k-k from:body -> (async (d) => { | 370 | + | k-gh from:body -> ermite.go('.brand'); |
| 376 | - | if (q('dialog[open]').count) return; | 371 | + | k-s from:body -> ermite.tab('s'); |
| 377 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 372 | + | k-t from:body -> ermite.tab('t'); |
| 378 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 373 | + | k-c from:body -> ermite.tab('c'); |
| 379 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 374 | + | k-r from:body -> ermite.tab('r'); |
| 380 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 375 | + | k-m from:body -> ermite.tab('m'); |
| 381 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 376 | + | k-next from:body -> ermite.nextNote(); |
| 382 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 377 | + | k-split from:body -> ermite.layout('split'); |
| 383 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 378 | + | k-unified from:body -> ermite.layout('unified'); |
| 384 | - | })(-1); | 379 | + | k-collapse from:body -> ermite.collapse(); |
| 385 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 380 | + | k-permalink from:body -> ermite.permalink()"> |
| 386 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 387 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 388 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 389 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 390 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 391 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 392 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 393 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 394 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 395 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 396 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 397 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 398 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 399 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 400 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 401 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 402 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 403 | - | k-next from:body -> { | |||
| 404 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 405 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 406 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 407 | - | }; | |||
| 408 | - | k-split from:body -> { | |||
| 409 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 410 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 411 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 412 | - | }; | |||
| 413 | - | k-unified from:body -> { | |||
| 414 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 415 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 416 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 417 | - | }; | |||
| 418 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 419 | - | k-permalink from:body -> { | |||
| 420 | - | let b = document.body.dataset; | |||
| 421 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 422 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 423 | - | navigator.clipboard?.writeText(location.href); | |||
| 424 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 425 | - | }"> | |||
| 426 | </div> | 381 | </div> | ||
| 427 | </body> | 382 | </body> | ||
| 428 | </html> | 383 | </html> | ||
R cmd/ermite/testdata/web/blob-too-large.html → internal/app/testdata/web/blob-too-large.html +28 -73 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -230,7 +231,7 @@ | |||||
| 230 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 231 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 231 | <div class="panel-head"> | 232 | <div class="panel-head"> | ||
| 232 | <label for="refs-filter" class="sr">filter branches and tags</label> | 233 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 233 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 234 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 234 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 235 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 235 | <kbd>esc</kbd> | 236 | <kbd>esc</kbd> | ||
| 236 | </button> | 237 | </button> | ||
| @@ -265,13 +266,7 @@ | |||||
| 265 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 266 | <div class="panel-head"> | 267 | <div class="panel-head"> | ||
| 267 | <label for="cmd-q" class="sr">go to</label> | 268 | <label for="cmd-q" class="sr">go to</label> | ||
| 268 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 269 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 270 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 271 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 272 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 273 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 274 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 275 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 276 | <kbd>esc</kbd> | 271 | <kbd>esc</kbd> | ||
| 277 | </button> | 272 | </button> | ||
| @@ -342,67 +337,27 @@ | |||||
| 342 | <kbd>esc</kbd> close</span> | 337 | <kbd>esc</kbd> close</span> | ||
| 343 | </p> | 338 | </p> | ||
| 344 | </dialog> | 339 | </dialog> | ||
| 345 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 340 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 346 | - | if (q('dialog[open]').count) return; | 341 | + | k-k from:body -> ermite.move(-1); |
| 347 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 342 | + | k-top from:body -> ermite.edge(false); |
| 348 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 343 | + | k-bottom from:body -> ermite.edge(true); |
| 349 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 344 | + | k-open from:body -> ermite.open(); |
| 350 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 345 | + | k-esc from:body -> ermite.closeDialogs(); |
| 351 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 346 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 352 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 347 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 353 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 348 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 354 | - | })(1); | 349 | + | k-filter from:body -> ermite.focusFilter(); |
| 355 | - | k-k from:body -> (async (d) => { | 350 | + | k-gh from:body -> ermite.go('.brand'); |
| 356 | - | if (q('dialog[open]').count) return; | 351 | + | k-s from:body -> ermite.tab('s'); |
| 357 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 352 | + | k-t from:body -> ermite.tab('t'); |
| 358 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 353 | + | k-c from:body -> ermite.tab('c'); |
| 359 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 354 | + | k-r from:body -> ermite.tab('r'); |
| 360 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 355 | + | k-m from:body -> ermite.tab('m'); |
| 361 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 356 | + | k-next from:body -> ermite.nextNote(); |
| 362 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 357 | + | k-split from:body -> ermite.layout('split'); |
| 363 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 358 | + | k-unified from:body -> ermite.layout('unified'); |
| 364 | - | })(-1); | 359 | + | k-collapse from:body -> ermite.collapse(); |
| 365 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 360 | + | k-permalink from:body -> ermite.permalink()"> |
| 366 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 367 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 368 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 369 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 370 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 371 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 372 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 373 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 374 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 375 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 376 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 377 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 378 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 379 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 380 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 381 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 382 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 383 | - | k-next from:body -> { | |||
| 384 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 385 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 386 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 387 | - | }; | |||
| 388 | - | k-split from:body -> { | |||
| 389 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 390 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 391 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 392 | - | }; | |||
| 393 | - | k-unified from:body -> { | |||
| 394 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 395 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 396 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 397 | - | }; | |||
| 398 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 399 | - | k-permalink from:body -> { | |||
| 400 | - | let b = document.body.dataset; | |||
| 401 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 402 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 403 | - | navigator.clipboard?.writeText(location.href); | |||
| 404 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 405 | - | }"> | |||
| 406 | </div> | 361 | </div> | ||
| 407 | </body> | 362 | </body> | ||
| 408 | </html> | 363 | </html> | ||
R cmd/ermite/testdata/web/blob-trojan.html → internal/app/testdata/web/blob-trojan.html +29 -74 renamed, 63% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -102,7 +103,7 @@ | |||||
| 102 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | ||
| 103 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | ||
| 104 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | ||
| 105 | - | <code> | 106 | + | <code hx-ignore> |
| 106 | <span class="line" id="L1"> | 107 | <span class="line" id="L1"> | ||
| 107 | <a class="ln" href="#L1">1</a> | 108 | <a class="ln" href="#L1">1</a> | ||
| 108 | <span class="kn">package</span> | 109 | <span class="kn">package</span> | ||
| @@ -297,7 +298,7 @@ | |||||
| 297 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 298 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 298 | <div class="panel-head"> | 299 | <div class="panel-head"> | ||
| 299 | <label for="refs-filter" class="sr">filter branches and tags</label> | 300 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 300 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 301 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 301 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 302 | <kbd>esc</kbd> | 303 | <kbd>esc</kbd> | ||
| 303 | </button> | 304 | </button> | ||
| @@ -332,13 +333,7 @@ | |||||
| 332 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 333 | <div class="panel-head"> | 334 | <div class="panel-head"> | ||
| 334 | <label for="cmd-q" class="sr">go to</label> | 335 | <label for="cmd-q" class="sr">go to</label> | ||
| 335 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 336 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 337 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 338 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 339 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 340 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 341 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 342 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 337 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 343 | <kbd>esc</kbd> | 338 | <kbd>esc</kbd> | ||
| 344 | </button> | 339 | </button> | ||
| @@ -409,67 +404,27 @@ | |||||
| 409 | <kbd>esc</kbd> close</span> | 404 | <kbd>esc</kbd> close</span> | ||
| 410 | </p> | 405 | </p> | ||
| 411 | </dialog> | 406 | </dialog> | ||
| 412 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 407 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 413 | - | if (q('dialog[open]').count) return; | 408 | + | k-k from:body -> ermite.move(-1); |
| 414 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 409 | + | k-top from:body -> ermite.edge(false); |
| 415 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 410 | + | k-bottom from:body -> ermite.edge(true); |
| 416 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 411 | + | k-open from:body -> ermite.open(); |
| 417 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 412 | + | k-esc from:body -> ermite.closeDialogs(); |
| 418 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 413 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 419 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 414 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 420 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 415 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 421 | - | })(1); | 416 | + | k-filter from:body -> ermite.focusFilter(); |
| 422 | - | k-k from:body -> (async (d) => { | 417 | + | k-gh from:body -> ermite.go('.brand'); |
| 423 | - | if (q('dialog[open]').count) return; | 418 | + | k-s from:body -> ermite.tab('s'); |
| 424 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 419 | + | k-t from:body -> ermite.tab('t'); |
| 425 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 420 | + | k-c from:body -> ermite.tab('c'); |
| 426 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 421 | + | k-r from:body -> ermite.tab('r'); |
| 427 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 422 | + | k-m from:body -> ermite.tab('m'); |
| 428 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 423 | + | k-next from:body -> ermite.nextNote(); |
| 429 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 424 | + | k-split from:body -> ermite.layout('split'); |
| 430 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 425 | + | k-unified from:body -> ermite.layout('unified'); |
| 431 | - | })(-1); | 426 | + | k-collapse from:body -> ermite.collapse(); |
| 432 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 427 | + | k-permalink from:body -> ermite.permalink()"> |
| 433 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 434 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 435 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 436 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 437 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 438 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 439 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 440 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 441 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 442 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 443 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 444 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 445 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 446 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 447 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 448 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 449 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 450 | - | k-next from:body -> { | |||
| 451 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 452 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 453 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 454 | - | }; | |||
| 455 | - | k-split from:body -> { | |||
| 456 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 457 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 458 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 459 | - | }; | |||
| 460 | - | k-unified from:body -> { | |||
| 461 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 462 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 463 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 464 | - | }; | |||
| 465 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 466 | - | k-permalink from:body -> { | |||
| 467 | - | let b = document.body.dataset; | |||
| 468 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 469 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 470 | - | navigator.clipboard?.writeText(location.href); | |||
| 471 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 472 | - | }"> | |||
| 473 | </div> | 428 | </div> | ||
| 474 | </body> | 429 | </body> | ||
| 475 | </html> | 430 | </html> | ||
R cmd/ermite/testdata/web/commit-big.html → internal/app/testdata/web/commit-big.html +32 -77 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -107,11 +108,11 @@ | |||||
| 107 | </span> | 108 | </span> | ||
| 108 | <span class="grow"> | 109 | <span class="grow"> | ||
| 109 | </span> | 110 | </span> | ||
| 110 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 111 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 111 | </a> | 112 | </a> | ||
| 112 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 113 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 113 | </a> | 114 | </a> | ||
| 114 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 115 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 115 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 116 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 116 | <kbd aria-hidden="true">x</kbd> | 117 | <kbd aria-hidden="true">x</kbd> | ||
| 117 | </button> | 118 | </button> | ||
| @@ -136,7 +137,7 @@ | |||||
| 136 | </h2> | 137 | </h2> | ||
| 137 | <ol class="files" aria-label="Changed files"> | 138 | <ol class="files" aria-label="Changed files"> | ||
| 138 | <li> | 139 | <li> | ||
| 139 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 140 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 140 | <span class="st" title="A">A</span> | 141 | <span class="st" title="A">A</span> | ||
| 141 | <span class="p" title="big.txt">big.txt</span> | 142 | <span class="p" title="big.txt">big.txt</span> | ||
| 142 | <span class="n"> | 143 | <span class="n"> | ||
| @@ -267,7 +268,7 @@ | |||||
| 267 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 268 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 268 | <div class="panel-head"> | 269 | <div class="panel-head"> | ||
| 269 | <label for="refs-filter" class="sr">filter branches and tags</label> | 270 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 270 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 271 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 271 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 272 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 272 | <kbd>esc</kbd> | 273 | <kbd>esc</kbd> | ||
| 273 | </button> | 274 | </button> | ||
| @@ -302,13 +303,7 @@ | |||||
| 302 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 303 | <div class="panel-head"> | 304 | <div class="panel-head"> | ||
| 304 | <label for="cmd-q" class="sr">go to</label> | 305 | <label for="cmd-q" class="sr">go to</label> | ||
| 305 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 306 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 307 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 308 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 309 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 310 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 311 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 312 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 307 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 313 | <kbd>esc</kbd> | 308 | <kbd>esc</kbd> | ||
| 314 | </button> | 309 | </button> | ||
| @@ -379,67 +374,27 @@ | |||||
| 379 | <kbd>esc</kbd> close</span> | 374 | <kbd>esc</kbd> close</span> | ||
| 380 | </p> | 375 | </p> | ||
| 381 | </dialog> | 376 | </dialog> | ||
| 382 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 377 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 383 | - | if (q('dialog[open]').count) return; | 378 | + | k-k from:body -> ermite.move(-1); |
| 384 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 379 | + | k-top from:body -> ermite.edge(false); |
| 385 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 380 | + | k-bottom from:body -> ermite.edge(true); |
| 386 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 381 | + | k-open from:body -> ermite.open(); |
| 387 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 382 | + | k-esc from:body -> ermite.closeDialogs(); |
| 388 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 383 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 389 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 384 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 390 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 385 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 391 | - | })(1); | 386 | + | k-filter from:body -> ermite.focusFilter(); |
| 392 | - | k-k from:body -> (async (d) => { | 387 | + | k-gh from:body -> ermite.go('.brand'); |
| 393 | - | if (q('dialog[open]').count) return; | 388 | + | k-s from:body -> ermite.tab('s'); |
| 394 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 389 | + | k-t from:body -> ermite.tab('t'); |
| 395 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 390 | + | k-c from:body -> ermite.tab('c'); |
| 396 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 391 | + | k-r from:body -> ermite.tab('r'); |
| 397 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 392 | + | k-m from:body -> ermite.tab('m'); |
| 398 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 393 | + | k-next from:body -> ermite.nextNote(); |
| 399 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 394 | + | k-split from:body -> ermite.layout('split'); |
| 400 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 395 | + | k-unified from:body -> ermite.layout('unified'); |
| 401 | - | })(-1); | 396 | + | k-collapse from:body -> ermite.collapse(); |
| 402 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 397 | + | k-permalink from:body -> ermite.permalink()"> |
| 403 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 404 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 405 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 406 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 407 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 408 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 409 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 410 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 411 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 412 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 413 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 414 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 415 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 416 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 417 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 418 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 419 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 420 | - | k-next from:body -> { | |||
| 421 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 422 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 423 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 424 | - | }; | |||
| 425 | - | k-split from:body -> { | |||
| 426 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 427 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 428 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 429 | - | }; | |||
| 430 | - | k-unified from:body -> { | |||
| 431 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 432 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 433 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 434 | - | }; | |||
| 435 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 436 | - | k-permalink from:body -> { | |||
| 437 | - | let b = document.body.dataset; | |||
| 438 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 439 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 440 | - | navigator.clipboard?.writeText(location.href); | |||
| 441 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 442 | - | }"> | |||
| 443 | </div> | 398 | </div> | ||
| 444 | </body> | 399 | </body> | ||
| 445 | </html> | 400 | </html> | ||
R cmd/ermite/testdata/web/commit-binary.html → internal/app/testdata/web/commit-binary.html +32 -77 renamed, 55% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -103,11 +104,11 @@ | |||||
| 103 | </span> | 104 | </span> | ||
| 104 | <span class="grow"> | 105 | <span class="grow"> | ||
| 105 | </span> | 106 | </span> | ||
| 106 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 107 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 107 | </a> | 108 | </a> | ||
| 108 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 109 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 109 | </a> | 110 | </a> | ||
| 110 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 111 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 111 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 112 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 112 | <kbd aria-hidden="true">x</kbd> | 113 | <kbd aria-hidden="true">x</kbd> | ||
| 113 | </button> | 114 | </button> | ||
| @@ -131,7 +132,7 @@ | |||||
| 131 | </h2> | 132 | </h2> | ||
| 132 | <ol class="files" aria-label="Changed files"> | 133 | <ol class="files" aria-label="Changed files"> | ||
| 133 | <li> | 134 | <li> | ||
| 134 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 135 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 135 | <span class="st" title="M">M</span> | 136 | <span class="st" title="M">M</span> | ||
| 136 | <span class="p" title="logo.bin">logo.bin</span> | 137 | <span class="p" title="logo.bin">logo.bin</span> | ||
| 137 | <span class="n"> | 138 | <span class="n"> | ||
| @@ -262,7 +263,7 @@ | |||||
| 262 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 263 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 263 | <div class="panel-head"> | 264 | <div class="panel-head"> | ||
| 264 | <label for="refs-filter" class="sr">filter branches and tags</label> | 265 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 265 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 266 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 266 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 267 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 267 | <kbd>esc</kbd> | 268 | <kbd>esc</kbd> | ||
| 268 | </button> | 269 | </button> | ||
| @@ -297,13 +298,7 @@ | |||||
| 297 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 298 | <div class="panel-head"> | 299 | <div class="panel-head"> | ||
| 299 | <label for="cmd-q" class="sr">go to</label> | 300 | <label for="cmd-q" class="sr">go to</label> | ||
| 300 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 301 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 302 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 303 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 304 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 305 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 306 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 307 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 308 | <kbd>esc</kbd> | 303 | <kbd>esc</kbd> | ||
| 309 | </button> | 304 | </button> | ||
| @@ -374,67 +369,27 @@ | |||||
| 374 | <kbd>esc</kbd> close</span> | 369 | <kbd>esc</kbd> close</span> | ||
| 375 | </p> | 370 | </p> | ||
| 376 | </dialog> | 371 | </dialog> | ||
| 377 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 372 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 378 | - | if (q('dialog[open]').count) return; | 373 | + | k-k from:body -> ermite.move(-1); |
| 379 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 374 | + | k-top from:body -> ermite.edge(false); |
| 380 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 375 | + | k-bottom from:body -> ermite.edge(true); |
| 381 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 376 | + | k-open from:body -> ermite.open(); |
| 382 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 377 | + | k-esc from:body -> ermite.closeDialogs(); |
| 383 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 378 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 384 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 379 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 385 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 380 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 386 | - | })(1); | 381 | + | k-filter from:body -> ermite.focusFilter(); |
| 387 | - | k-k from:body -> (async (d) => { | 382 | + | k-gh from:body -> ermite.go('.brand'); |
| 388 | - | if (q('dialog[open]').count) return; | 383 | + | k-s from:body -> ermite.tab('s'); |
| 389 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 384 | + | k-t from:body -> ermite.tab('t'); |
| 390 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 385 | + | k-c from:body -> ermite.tab('c'); |
| 391 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 386 | + | k-r from:body -> ermite.tab('r'); |
| 392 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 387 | + | k-m from:body -> ermite.tab('m'); |
| 393 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 388 | + | k-next from:body -> ermite.nextNote(); |
| 394 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 389 | + | k-split from:body -> ermite.layout('split'); |
| 395 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 390 | + | k-unified from:body -> ermite.layout('unified'); |
| 396 | - | })(-1); | 391 | + | k-collapse from:body -> ermite.collapse(); |
| 397 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 392 | + | k-permalink from:body -> ermite.permalink()"> |
| 398 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 399 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 400 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 401 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 402 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 403 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 404 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 405 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 406 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 407 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 408 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 409 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 410 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 411 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 412 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 413 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 414 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 415 | - | k-next from:body -> { | |||
| 416 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 417 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 418 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 419 | - | }; | |||
| 420 | - | k-split from:body -> { | |||
| 421 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 422 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 423 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 424 | - | }; | |||
| 425 | - | k-unified from:body -> { | |||
| 426 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 427 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 428 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 429 | - | }; | |||
| 430 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 431 | - | k-permalink from:body -> { | |||
| 432 | - | let b = document.body.dataset; | |||
| 433 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 434 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 435 | - | navigator.clipboard?.writeText(location.href); | |||
| 436 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 437 | - | }"> | |||
| 438 | </div> | 393 | </div> | ||
| 439 | </body> | 394 | </body> | ||
| 440 | </html> | 395 | </html> | ||
R cmd/ermite/testdata/web/commit-merge.html → internal/app/testdata/web/commit-merge.html +32 -77 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -102,11 +103,11 @@ | |||||
| 102 | </span> | 103 | </span> | ||
| 103 | <span class="grow"> | 104 | <span class="grow"> | ||
| 104 | </span> | 105 | </span> | ||
| 105 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 106 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 106 | </a> | 107 | </a> | ||
| 107 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 108 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 108 | </a> | 109 | </a> | ||
| 109 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 110 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 110 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 111 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 111 | <kbd aria-hidden="true">x</kbd> | 112 | <kbd aria-hidden="true">x</kbd> | ||
| 112 | </button> | 113 | </button> | ||
| @@ -170,7 +171,7 @@ | |||||
| 170 | </h2> | 171 | </h2> | ||
| 171 | <ol class="files" aria-label="Changed files"> | 172 | <ol class="files" aria-label="Changed files"> | ||
| 172 | <li> | 173 | <li> | ||
| 173 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 174 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 174 | <span class="st" title="A">A</span> | 175 | <span class="st" title="A">A</span> | ||
| 175 | <span class="p" title="side.txt">side.txt</span> | 176 | <span class="p" title="side.txt">side.txt</span> | ||
| 176 | <span class="n"> | 177 | <span class="n"> | ||
| @@ -301,7 +302,7 @@ | |||||
| 301 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 302 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 302 | <div class="panel-head"> | 303 | <div class="panel-head"> | ||
| 303 | <label for="refs-filter" class="sr">filter branches and tags</label> | 304 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 304 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 305 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 305 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 306 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 306 | <kbd>esc</kbd> | 307 | <kbd>esc</kbd> | ||
| 307 | </button> | 308 | </button> | ||
| @@ -336,13 +337,7 @@ | |||||
| 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()"> | 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()"> | ||
| 337 | <div class="panel-head"> | 338 | <div class="panel-head"> | ||
| 338 | <label for="cmd-q" class="sr">go to</label> | 339 | <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="let t = this.value.toLowerCase().trim(); | 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)"> |
| 340 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 341 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 342 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 343 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 344 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 345 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 346 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 341 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 347 | <kbd>esc</kbd> | 342 | <kbd>esc</kbd> | ||
| 348 | </button> | 343 | </button> | ||
| @@ -413,67 +408,27 @@ | |||||
| 413 | <kbd>esc</kbd> close</span> | 408 | <kbd>esc</kbd> close</span> | ||
| 414 | </p> | 409 | </p> | ||
| 415 | </dialog> | 410 | </dialog> | ||
| 416 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 411 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 417 | - | if (q('dialog[open]').count) return; | 412 | + | k-k from:body -> ermite.move(-1); |
| 418 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 413 | + | k-top from:body -> ermite.edge(false); |
| 419 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 414 | + | k-bottom from:body -> ermite.edge(true); |
| 420 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 415 | + | k-open from:body -> ermite.open(); |
| 421 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 416 | + | k-esc from:body -> ermite.closeDialogs(); |
| 422 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 417 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 423 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 418 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 424 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 419 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 425 | - | })(1); | 420 | + | k-filter from:body -> ermite.focusFilter(); |
| 426 | - | k-k from:body -> (async (d) => { | 421 | + | k-gh from:body -> ermite.go('.brand'); |
| 427 | - | if (q('dialog[open]').count) return; | 422 | + | k-s from:body -> ermite.tab('s'); |
| 428 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 423 | + | k-t from:body -> ermite.tab('t'); |
| 429 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 424 | + | k-c from:body -> ermite.tab('c'); |
| 430 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 425 | + | k-r from:body -> ermite.tab('r'); |
| 431 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 426 | + | k-m from:body -> ermite.tab('m'); |
| 432 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 427 | + | k-next from:body -> ermite.nextNote(); |
| 433 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 428 | + | k-split from:body -> ermite.layout('split'); |
| 434 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 429 | + | k-unified from:body -> ermite.layout('unified'); |
| 435 | - | })(-1); | 430 | + | k-collapse from:body -> ermite.collapse(); |
| 436 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 431 | + | k-permalink from:body -> ermite.permalink()"> |
| 437 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 438 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 439 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 440 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 441 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 442 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 443 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 444 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 445 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 446 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 447 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 448 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 449 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 450 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 451 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 452 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 453 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 454 | - | k-next from:body -> { | |||
| 455 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 456 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 457 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 458 | - | }; | |||
| 459 | - | k-split from:body -> { | |||
| 460 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 461 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 462 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 463 | - | }; | |||
| 464 | - | k-unified from:body -> { | |||
| 465 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 466 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 467 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 468 | - | }; | |||
| 469 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 470 | - | k-permalink from:body -> { | |||
| 471 | - | let b = document.body.dataset; | |||
| 472 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 473 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 474 | - | navigator.clipboard?.writeText(location.href); | |||
| 475 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 476 | - | }"> | |||
| 477 | </div> | 432 | </div> | ||
| 478 | </body> | 433 | </body> | ||
| 479 | </html> | 434 | </html> | ||
R cmd/ermite/testdata/web/commit-modify-split.html → internal/app/testdata/web/commit-modify-split.html +33 -78 renamed, 61% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -109,11 +110,11 @@ And <b>html</b> & "quotes".</pre> | |||||
| 109 | </span> | 110 | </span> | ||
| 110 | <span class="grow"> | 111 | <span class="grow"> | ||
| 111 | </span> | 112 | </span> | ||
| 112 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 113 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 113 | </a> | 114 | </a> | ||
| 114 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 115 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 115 | </a> | 116 | </a> | ||
| 116 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 117 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 117 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 118 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 118 | <kbd aria-hidden="true">x</kbd> | 119 | <kbd aria-hidden="true">x</kbd> | ||
| 119 | </button> | 120 | </button> | ||
| @@ -277,7 +278,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 277 | </h2> | 278 | </h2> | ||
| 278 | <ol class="files" aria-label="Changed files"> | 279 | <ol class="files" aria-label="Changed files"> | ||
| 279 | <li> | 280 | <li> | ||
| 280 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 281 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 281 | <span class="st" title="M">M</span> | 282 | <span class="st" title="M">M</span> | ||
| 282 | <span class="p" title="a.txt">a.txt</span> | 283 | <span class="p" title="a.txt">a.txt</span> | ||
| 283 | <span class="n"> | 284 | <span class="n"> | ||
| @@ -291,7 +292,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 291 | </a> | 292 | </a> | ||
| 292 | </li> | 293 | </li> | ||
| 293 | <li> | 294 | <li> | ||
| 294 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 295 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 295 | <span class="st" title="M">M</span> | 296 | <span class="st" title="M">M</span> | ||
| 296 | <span class="p" title="run.sh">run.sh</span> | 297 | <span class="p" title="run.sh">run.sh</span> | ||
| 297 | <span class="n"> | 298 | <span class="n"> | ||
| @@ -422,7 +423,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 422 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 423 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 423 | <div class="panel-head"> | 424 | <div class="panel-head"> | ||
| 424 | <label for="refs-filter" class="sr">filter branches and tags</label> | 425 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 425 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 426 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 426 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 427 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 427 | <kbd>esc</kbd> | 428 | <kbd>esc</kbd> | ||
| 428 | </button> | 429 | </button> | ||
| @@ -457,13 +458,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 457 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 458 | <div class="panel-head"> | 459 | <div class="panel-head"> | ||
| 459 | <label for="cmd-q" class="sr">go to</label> | 460 | <label for="cmd-q" class="sr">go to</label> | ||
| 460 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 461 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 462 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 463 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 464 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 465 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 466 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 467 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 462 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 468 | <kbd>esc</kbd> | 463 | <kbd>esc</kbd> | ||
| 469 | </button> | 464 | </button> | ||
| @@ -534,67 +529,27 @@ And <b>html</b> & "quotes".</pre> | |||||
| 534 | <kbd>esc</kbd> close</span> | 529 | <kbd>esc</kbd> close</span> | ||
| 535 | </p> | 530 | </p> | ||
| 536 | </dialog> | 531 | </dialog> | ||
| 537 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 532 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 538 | - | if (q('dialog[open]').count) return; | 533 | + | k-k from:body -> ermite.move(-1); |
| 539 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 534 | + | k-top from:body -> ermite.edge(false); |
| 540 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 535 | + | k-bottom from:body -> ermite.edge(true); |
| 541 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 536 | + | k-open from:body -> ermite.open(); |
| 542 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 537 | + | k-esc from:body -> ermite.closeDialogs(); |
| 543 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 538 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 544 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 539 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 545 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 540 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 546 | - | })(1); | 541 | + | k-filter from:body -> ermite.focusFilter(); |
| 547 | - | k-k from:body -> (async (d) => { | 542 | + | k-gh from:body -> ermite.go('.brand'); |
| 548 | - | if (q('dialog[open]').count) return; | 543 | + | k-s from:body -> ermite.tab('s'); |
| 549 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 544 | + | k-t from:body -> ermite.tab('t'); |
| 550 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 545 | + | k-c from:body -> ermite.tab('c'); |
| 551 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 546 | + | k-r from:body -> ermite.tab('r'); |
| 552 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 547 | + | k-m from:body -> ermite.tab('m'); |
| 553 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 548 | + | k-next from:body -> ermite.nextNote(); |
| 554 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 549 | + | k-split from:body -> ermite.layout('split'); |
| 555 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 550 | + | k-unified from:body -> ermite.layout('unified'); |
| 556 | - | })(-1); | 551 | + | k-collapse from:body -> ermite.collapse(); |
| 557 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 552 | + | k-permalink from:body -> ermite.permalink()"> |
| 558 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 559 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 560 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 561 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 562 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 563 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 564 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 565 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 566 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 567 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 568 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 569 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 570 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 571 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 572 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 573 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 574 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 575 | - | k-next from:body -> { | |||
| 576 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 577 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 578 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 579 | - | }; | |||
| 580 | - | k-split from:body -> { | |||
| 581 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 582 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 583 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 584 | - | }; | |||
| 585 | - | k-unified from:body -> { | |||
| 586 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 587 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 588 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 589 | - | }; | |||
| 590 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 591 | - | k-permalink from:body -> { | |||
| 592 | - | let b = document.body.dataset; | |||
| 593 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 594 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 595 | - | navigator.clipboard?.writeText(location.href); | |||
| 596 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 597 | - | }"> | |||
| 598 | </div> | 553 | </div> | ||
| 599 | </body> | 554 | </body> | ||
| 600 | </html> | 555 | </html> | ||
R cmd/ermite/testdata/web/commit-modify.html → internal/app/testdata/web/commit-modify.html +33 -78 renamed, 61% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -109,11 +110,11 @@ And <b>html</b> & "quotes".</pre> | |||||
| 109 | </span> | 110 | </span> | ||
| 110 | <span class="grow"> | 111 | <span class="grow"> | ||
| 111 | </span> | 112 | </span> | ||
| 112 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 113 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 113 | </a> | 114 | </a> | ||
| 114 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 115 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 115 | </a> | 116 | </a> | ||
| 116 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 117 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 117 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 118 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 118 | <kbd aria-hidden="true">x</kbd> | 119 | <kbd aria-hidden="true">x</kbd> | ||
| 119 | </button> | 120 | </button> | ||
| @@ -277,7 +278,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 277 | </h2> | 278 | </h2> | ||
| 278 | <ol class="files" aria-label="Changed files"> | 279 | <ol class="files" aria-label="Changed files"> | ||
| 279 | <li> | 280 | <li> | ||
| 280 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 281 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 281 | <span class="st" title="M">M</span> | 282 | <span class="st" title="M">M</span> | ||
| 282 | <span class="p" title="a.txt">a.txt</span> | 283 | <span class="p" title="a.txt">a.txt</span> | ||
| 283 | <span class="n"> | 284 | <span class="n"> | ||
| @@ -291,7 +292,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 291 | </a> | 292 | </a> | ||
| 292 | </li> | 293 | </li> | ||
| 293 | <li> | 294 | <li> | ||
| 294 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 295 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 295 | <span class="st" title="M">M</span> | 296 | <span class="st" title="M">M</span> | ||
| 296 | <span class="p" title="run.sh">run.sh</span> | 297 | <span class="p" title="run.sh">run.sh</span> | ||
| 297 | <span class="n"> | 298 | <span class="n"> | ||
| @@ -422,7 +423,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 422 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 423 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 423 | <div class="panel-head"> | 424 | <div class="panel-head"> | ||
| 424 | <label for="refs-filter" class="sr">filter branches and tags</label> | 425 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 425 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 426 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 426 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 427 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 427 | <kbd>esc</kbd> | 428 | <kbd>esc</kbd> | ||
| 428 | </button> | 429 | </button> | ||
| @@ -457,13 +458,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 457 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 458 | <div class="panel-head"> | 459 | <div class="panel-head"> | ||
| 459 | <label for="cmd-q" class="sr">go to</label> | 460 | <label for="cmd-q" class="sr">go to</label> | ||
| 460 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 461 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 462 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 463 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 464 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 465 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 466 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 467 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 462 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 468 | <kbd>esc</kbd> | 463 | <kbd>esc</kbd> | ||
| 469 | </button> | 464 | </button> | ||
| @@ -534,67 +529,27 @@ And <b>html</b> & "quotes".</pre> | |||||
| 534 | <kbd>esc</kbd> close</span> | 529 | <kbd>esc</kbd> close</span> | ||
| 535 | </p> | 530 | </p> | ||
| 536 | </dialog> | 531 | </dialog> | ||
| 537 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 532 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 538 | - | if (q('dialog[open]').count) return; | 533 | + | k-k from:body -> ermite.move(-1); |
| 539 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 534 | + | k-top from:body -> ermite.edge(false); |
| 540 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 535 | + | k-bottom from:body -> ermite.edge(true); |
| 541 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 536 | + | k-open from:body -> ermite.open(); |
| 542 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 537 | + | k-esc from:body -> ermite.closeDialogs(); |
| 543 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 538 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 544 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 539 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 545 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 540 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 546 | - | })(1); | 541 | + | k-filter from:body -> ermite.focusFilter(); |
| 547 | - | k-k from:body -> (async (d) => { | 542 | + | k-gh from:body -> ermite.go('.brand'); |
| 548 | - | if (q('dialog[open]').count) return; | 543 | + | k-s from:body -> ermite.tab('s'); |
| 549 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 544 | + | k-t from:body -> ermite.tab('t'); |
| 550 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 545 | + | k-c from:body -> ermite.tab('c'); |
| 551 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 546 | + | k-r from:body -> ermite.tab('r'); |
| 552 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 547 | + | k-m from:body -> ermite.tab('m'); |
| 553 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 548 | + | k-next from:body -> ermite.nextNote(); |
| 554 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 549 | + | k-split from:body -> ermite.layout('split'); |
| 555 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 550 | + | k-unified from:body -> ermite.layout('unified'); |
| 556 | - | })(-1); | 551 | + | k-collapse from:body -> ermite.collapse(); |
| 557 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 552 | + | k-permalink from:body -> ermite.permalink()"> |
| 558 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 559 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 560 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 561 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 562 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 563 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 564 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 565 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 566 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 567 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 568 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 569 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 570 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 571 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 572 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 573 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 574 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 575 | - | k-next from:body -> { | |||
| 576 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 577 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 578 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 579 | - | }; | |||
| 580 | - | k-split from:body -> { | |||
| 581 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 582 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 583 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 584 | - | }; | |||
| 585 | - | k-unified from:body -> { | |||
| 586 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 587 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 588 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 589 | - | }; | |||
| 590 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 591 | - | k-permalink from:body -> { | |||
| 592 | - | let b = document.body.dataset; | |||
| 593 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 594 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 595 | - | navigator.clipboard?.writeText(location.href); | |||
| 596 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 597 | - | }"> | |||
| 598 | </div> | 553 | </div> | ||
| 599 | </body> | 554 | </body> | ||
| 600 | </html> | 555 | </html> | ||
R cmd/ermite/testdata/web/commit-rename.html → internal/app/testdata/web/commit-rename.html +33 -78 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -103,11 +104,11 @@ | |||||
| 103 | </span> | 104 | </span> | ||
| 104 | <span class="grow"> | 105 | <span class="grow"> | ||
| 105 | </span> | 106 | </span> | ||
| 106 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 107 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 107 | </a> | 108 | </a> | ||
| 108 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 109 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 109 | </a> | 110 | </a> | ||
| 110 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 111 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 111 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 112 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 112 | <kbd aria-hidden="true">x</kbd> | 113 | <kbd aria-hidden="true">x</kbd> | ||
| 113 | </button> | 114 | </button> | ||
| @@ -181,7 +182,7 @@ | |||||
| 181 | </h2> | 182 | </h2> | ||
| 182 | <ol class="files" aria-label="Changed files"> | 183 | <ol class="files" aria-label="Changed files"> | ||
| 183 | <li> | 184 | <li> | ||
| 184 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 185 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 185 | <span class="st" title="R">R</span> | 186 | <span class="st" title="R">R</span> | ||
| 186 | <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span> | 187 | <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span> | ||
| 187 | <span class="n"> | 188 | <span class="n"> | ||
| @@ -196,7 +197,7 @@ | |||||
| 196 | </a> | 197 | </a> | ||
| 197 | </li> | 198 | </li> | ||
| 198 | <li> | 199 | <li> | ||
| 199 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 200 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 200 | <span class="st" title="D">D</span> | 201 | <span class="st" title="D">D</span> | ||
| 201 | <span class="p" title="gone.txt">gone.txt</span> | 202 | <span class="p" title="gone.txt">gone.txt</span> | ||
| 202 | <span class="n"> | 203 | <span class="n"> | ||
| @@ -327,7 +328,7 @@ | |||||
| 327 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 328 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 328 | <div class="panel-head"> | 329 | <div class="panel-head"> | ||
| 329 | <label for="refs-filter" class="sr">filter branches and tags</label> | 330 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 330 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 331 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 331 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 332 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 332 | <kbd>esc</kbd> | 333 | <kbd>esc</kbd> | ||
| 333 | </button> | 334 | </button> | ||
| @@ -362,13 +363,7 @@ | |||||
| 362 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 363 | <div class="panel-head"> | 364 | <div class="panel-head"> | ||
| 364 | <label for="cmd-q" class="sr">go to</label> | 365 | <label for="cmd-q" class="sr">go to</label> | ||
| 365 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 366 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 367 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 368 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 369 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 370 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 371 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 372 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 367 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 373 | <kbd>esc</kbd> | 368 | <kbd>esc</kbd> | ||
| 374 | </button> | 369 | </button> | ||
| @@ -439,67 +434,27 @@ | |||||
| 439 | <kbd>esc</kbd> close</span> | 434 | <kbd>esc</kbd> close</span> | ||
| 440 | </p> | 435 | </p> | ||
| 441 | </dialog> | 436 | </dialog> | ||
| 442 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 437 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 443 | - | if (q('dialog[open]').count) return; | 438 | + | k-k from:body -> ermite.move(-1); |
| 444 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 439 | + | k-top from:body -> ermite.edge(false); |
| 445 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 440 | + | k-bottom from:body -> ermite.edge(true); |
| 446 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 441 | + | k-open from:body -> ermite.open(); |
| 447 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 442 | + | k-esc from:body -> ermite.closeDialogs(); |
| 448 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 443 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 449 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 444 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 450 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 445 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 451 | - | })(1); | 446 | + | k-filter from:body -> ermite.focusFilter(); |
| 452 | - | k-k from:body -> (async (d) => { | 447 | + | k-gh from:body -> ermite.go('.brand'); |
| 453 | - | if (q('dialog[open]').count) return; | 448 | + | k-s from:body -> ermite.tab('s'); |
| 454 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 449 | + | k-t from:body -> ermite.tab('t'); |
| 455 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 450 | + | k-c from:body -> ermite.tab('c'); |
| 456 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 451 | + | k-r from:body -> ermite.tab('r'); |
| 457 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 452 | + | k-m from:body -> ermite.tab('m'); |
| 458 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 453 | + | k-next from:body -> ermite.nextNote(); |
| 459 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 454 | + | k-split from:body -> ermite.layout('split'); |
| 460 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 455 | + | k-unified from:body -> ermite.layout('unified'); |
| 461 | - | })(-1); | 456 | + | k-collapse from:body -> ermite.collapse(); |
| 462 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 457 | + | k-permalink from:body -> ermite.permalink()"> |
| 463 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 464 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 465 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 466 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 467 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 468 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 469 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 470 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 471 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 472 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 473 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 474 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 475 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 476 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 477 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 478 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 479 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 480 | - | k-next from:body -> { | |||
| 481 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 482 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 483 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 484 | - | }; | |||
| 485 | - | k-split from:body -> { | |||
| 486 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 487 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 488 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 489 | - | }; | |||
| 490 | - | k-unified from:body -> { | |||
| 491 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 492 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 493 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 494 | - | }; | |||
| 495 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 496 | - | k-permalink from:body -> { | |||
| 497 | - | let b = document.body.dataset; | |||
| 498 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 499 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 500 | - | navigator.clipboard?.writeText(location.href); | |||
| 501 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 502 | - | }"> | |||
| 503 | </div> | 458 | </div> | ||
| 504 | </body> | 459 | </body> | ||
| 505 | </html> | 460 | </html> | ||
R cmd/ermite/testdata/web/commit-root.html → internal/app/testdata/web/commit-root.html +36 -81 renamed, 64% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -101,11 +102,11 @@ | |||||
| 101 | </span> | 102 | </span> | ||
| 102 | <span class="grow"> | 103 | <span class="grow"> | ||
| 103 | </span> | 104 | </span> | ||
| 104 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 105 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 105 | </a> | 106 | </a> | ||
| 106 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 107 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 107 | </a> | 108 | </a> | ||
| 108 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 109 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 109 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 110 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 110 | <kbd aria-hidden="true">x</kbd> | 111 | <kbd aria-hidden="true">x</kbd> | ||
| 111 | </button> | 112 | </button> | ||
| @@ -455,7 +456,7 @@ | |||||
| 455 | </h2> | 456 | </h2> | ||
| 456 | <ol class="files" aria-label="Changed files"> | 457 | <ol class="files" aria-label="Changed files"> | ||
| 457 | <li> | 458 | <li> | ||
| 458 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 459 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 459 | <span class="st" title="A">A</span> | 460 | <span class="st" title="A">A</span> | ||
| 460 | <span class="p" title="a.txt">a.txt</span> | 461 | <span class="p" title="a.txt">a.txt</span> | ||
| 461 | <span class="n"> | 462 | <span class="n"> | ||
| @@ -470,7 +471,7 @@ | |||||
| 470 | </a> | 471 | </a> | ||
| 471 | </li> | 472 | </li> | ||
| 472 | <li> | 473 | <li> | ||
| 473 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 474 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 474 | <span class="st" title="A">A</span> | 475 | <span class="st" title="A">A</span> | ||
| 475 | <span class="p" title="gone.txt">gone.txt</span> | 476 | <span class="p" title="gone.txt">gone.txt</span> | ||
| 476 | <span class="n"> | 477 | <span class="n"> | ||
| @@ -484,7 +485,7 @@ | |||||
| 484 | </a> | 485 | </a> | ||
| 485 | </li> | 486 | </li> | ||
| 486 | <li> | 487 | <li> | ||
| 487 | - | <a href="#f3" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f3' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 488 | + | <a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 488 | <span class="st" title="A">A</span> | 489 | <span class="st" title="A">A</span> | ||
| 489 | <span class="p" title="logo.bin">logo.bin</span> | 490 | <span class="p" title="logo.bin">logo.bin</span> | ||
| 490 | <span class="n"> | 491 | <span class="n"> | ||
| @@ -499,7 +500,7 @@ | |||||
| 499 | </a> | 500 | </a> | ||
| 500 | </li> | 501 | </li> | ||
| 501 | <li> | 502 | <li> | ||
| 502 | - | <a href="#f4" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f4' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 503 | + | <a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 503 | <span class="st" title="A">A</span> | 504 | <span class="st" title="A">A</span> | ||
| 504 | <span class="p" title="old.txt">old.txt</span> | 505 | <span class="p" title="old.txt">old.txt</span> | ||
| 505 | <span class="n"> | 506 | <span class="n"> | ||
| @@ -513,7 +514,7 @@ | |||||
| 513 | </a> | 514 | </a> | ||
| 514 | </li> | 515 | </li> | ||
| 515 | <li> | 516 | <li> | ||
| 516 | - | <a href="#f5" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f5' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 517 | + | <a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 517 | <span class="st" title="A">A</span> | 518 | <span class="st" title="A">A</span> | ||
| 518 | <span class="p" title="run.sh">run.sh</span> | 519 | <span class="p" title="run.sh">run.sh</span> | ||
| 519 | <span class="n"> | 520 | <span class="n"> | ||
| @@ -643,7 +644,7 @@ | |||||
| 643 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 644 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 644 | <div class="panel-head"> | 645 | <div class="panel-head"> | ||
| 645 | <label for="refs-filter" class="sr">filter branches and tags</label> | 646 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 646 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 647 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 647 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 648 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 648 | <kbd>esc</kbd> | 649 | <kbd>esc</kbd> | ||
| 649 | </button> | 650 | </button> | ||
| @@ -678,13 +679,7 @@ | |||||
| 678 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 679 | <div class="panel-head"> | 680 | <div class="panel-head"> | ||
| 680 | <label for="cmd-q" class="sr">go to</label> | 681 | <label for="cmd-q" class="sr">go to</label> | ||
| 681 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 682 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 683 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 684 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 685 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 686 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 687 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 688 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 683 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 689 | <kbd>esc</kbd> | 684 | <kbd>esc</kbd> | ||
| 690 | </button> | 685 | </button> | ||
| @@ -755,67 +750,27 @@ | |||||
| 755 | <kbd>esc</kbd> close</span> | 750 | <kbd>esc</kbd> close</span> | ||
| 756 | </p> | 751 | </p> | ||
| 757 | </dialog> | 752 | </dialog> | ||
| 758 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 753 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 759 | - | if (q('dialog[open]').count) return; | 754 | + | k-k from:body -> ermite.move(-1); |
| 760 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 755 | + | k-top from:body -> ermite.edge(false); |
| 761 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 756 | + | k-bottom from:body -> ermite.edge(true); |
| 762 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 757 | + | k-open from:body -> ermite.open(); |
| 763 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 758 | + | k-esc from:body -> ermite.closeDialogs(); |
| 764 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 759 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 765 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 760 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 766 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 761 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 767 | - | })(1); | 762 | + | k-filter from:body -> ermite.focusFilter(); |
| 768 | - | k-k from:body -> (async (d) => { | 763 | + | k-gh from:body -> ermite.go('.brand'); |
| 769 | - | if (q('dialog[open]').count) return; | 764 | + | k-s from:body -> ermite.tab('s'); |
| 770 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 765 | + | k-t from:body -> ermite.tab('t'); |
| 771 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 766 | + | k-c from:body -> ermite.tab('c'); |
| 772 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 767 | + | k-r from:body -> ermite.tab('r'); |
| 773 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 768 | + | k-m from:body -> ermite.tab('m'); |
| 774 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 769 | + | k-next from:body -> ermite.nextNote(); |
| 775 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 770 | + | k-split from:body -> ermite.layout('split'); |
| 776 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 771 | + | k-unified from:body -> ermite.layout('unified'); |
| 777 | - | })(-1); | 772 | + | k-collapse from:body -> ermite.collapse(); |
| 778 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 773 | + | k-permalink from:body -> ermite.permalink()"> |
| 779 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 780 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 781 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 782 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 783 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 784 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 785 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 786 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 787 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 788 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 789 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 790 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 791 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 792 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 793 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 794 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 795 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 796 | - | k-next from:body -> { | |||
| 797 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 798 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 799 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 800 | - | }; | |||
| 801 | - | k-split from:body -> { | |||
| 802 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 803 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 804 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 805 | - | }; | |||
| 806 | - | k-unified from:body -> { | |||
| 807 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 808 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 809 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 810 | - | }; | |||
| 811 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 812 | - | k-permalink from:body -> { | |||
| 813 | - | let b = document.body.dataset; | |||
| 814 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 815 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 816 | - | navigator.clipboard?.writeText(location.href); | |||
| 817 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 818 | - | }"> | |||
| 819 | </div> | 774 | </div> | ||
| 820 | </body> | 775 | </body> | ||
| 821 | </html> | 776 | </html> | ||
R cmd/ermite/testdata/web/commit-trojan.html → internal/app/testdata/web/commit-trojan.html +32 -77 renamed, 63% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">detached</span> | 49 | <span class="ref-name">detached</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -104,11 +105,11 @@ | |||||
| 104 | </span> | 105 | </span> | ||
| 105 | <span class="grow"> | 106 | <span class="grow"> | ||
| 106 | </span> | 107 | </span> | ||
| 107 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 108 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 108 | </a> | 109 | </a> | ||
| 109 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 110 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 110 | </a> | 111 | </a> | ||
| 111 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | 112 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> |
| 112 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 113 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
| 113 | <kbd aria-hidden="true">x</kbd> | 114 | <kbd aria-hidden="true">x</kbd> | ||
| 114 | </button> | 115 | </button> | ||
| @@ -305,7 +306,7 @@ | |||||
| 305 | </h2> | 306 | </h2> | ||
| 306 | <ol class="files" aria-label="Changed files"> | 307 | <ol class="files" aria-label="Changed files"> | ||
| 307 | <li> | 308 | <li> | ||
| 308 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 309 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> |
| 309 | <span class="st" title="A">A</span> | 310 | <span class="st" title="A">A</span> | ||
| 310 | <span class="p" title="evil.go">evil.go</span> | 311 | <span class="p" title="evil.go">evil.go</span> | ||
| 311 | <span class="n"> | 312 | <span class="n"> | ||
| @@ -436,7 +437,7 @@ | |||||
| 436 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 437 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 437 | <div class="panel-head"> | 438 | <div class="panel-head"> | ||
| 438 | <label for="refs-filter" class="sr">filter branches and tags</label> | 439 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 439 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 440 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 440 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 441 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 441 | <kbd>esc</kbd> | 442 | <kbd>esc</kbd> | ||
| 442 | </button> | 443 | </button> | ||
| @@ -471,13 +472,7 @@ | |||||
| 471 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 472 | <div class="panel-head"> | 473 | <div class="panel-head"> | ||
| 473 | <label for="cmd-q" class="sr">go to</label> | 474 | <label for="cmd-q" class="sr">go to</label> | ||
| 474 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 475 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 476 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 477 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 478 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 479 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 480 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 481 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 476 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 482 | <kbd>esc</kbd> | 477 | <kbd>esc</kbd> | ||
| 483 | </button> | 478 | </button> | ||
| @@ -548,67 +543,27 @@ | |||||
| 548 | <kbd>esc</kbd> close</span> | 543 | <kbd>esc</kbd> close</span> | ||
| 549 | </p> | 544 | </p> | ||
| 550 | </dialog> | 545 | </dialog> | ||
| 551 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 546 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 552 | - | if (q('dialog[open]').count) return; | 547 | + | k-k from:body -> ermite.move(-1); |
| 553 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 548 | + | k-top from:body -> ermite.edge(false); |
| 554 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 549 | + | k-bottom from:body -> ermite.edge(true); |
| 555 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 550 | + | k-open from:body -> ermite.open(); |
| 556 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 551 | + | k-esc from:body -> ermite.closeDialogs(); |
| 557 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 552 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 558 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 553 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 559 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 554 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 560 | - | })(1); | 555 | + | k-filter from:body -> ermite.focusFilter(); |
| 561 | - | k-k from:body -> (async (d) => { | 556 | + | k-gh from:body -> ermite.go('.brand'); |
| 562 | - | if (q('dialog[open]').count) return; | 557 | + | k-s from:body -> ermite.tab('s'); |
| 563 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 558 | + | k-t from:body -> ermite.tab('t'); |
| 564 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 559 | + | k-c from:body -> ermite.tab('c'); |
| 565 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 560 | + | k-r from:body -> ermite.tab('r'); |
| 566 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 561 | + | k-m from:body -> ermite.tab('m'); |
| 567 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 562 | + | k-next from:body -> ermite.nextNote(); |
| 568 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 563 | + | k-split from:body -> ermite.layout('split'); |
| 569 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 564 | + | k-unified from:body -> ermite.layout('unified'); |
| 570 | - | })(-1); | 565 | + | k-collapse from:body -> ermite.collapse(); |
| 571 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 566 | + | k-permalink from:body -> ermite.permalink()"> |
| 572 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 573 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 574 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 575 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 576 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 577 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 578 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 579 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 580 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 581 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 582 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 583 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 584 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 585 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 586 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 587 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 588 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 589 | - | k-next from:body -> { | |||
| 590 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 591 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 592 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 593 | - | }; | |||
| 594 | - | k-split from:body -> { | |||
| 595 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 596 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 597 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 598 | - | }; | |||
| 599 | - | k-unified from:body -> { | |||
| 600 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 601 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 602 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 603 | - | }; | |||
| 604 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 605 | - | k-permalink from:body -> { | |||
| 606 | - | let b = document.body.dataset; | |||
| 607 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 608 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 609 | - | navigator.clipboard?.writeText(location.href); | |||
| 610 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 611 | - | }"> | |||
| 612 | </div> | 567 | </div> | ||
| 613 | </body> | 568 | </body> | ||
| 614 | </html> | 569 | </html> | ||
R cmd/ermite/testdata/web/commits-page2.html → internal/app/testdata/web/commits-page2.html +29 -76 renamed, 55% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">main</span> | 49 | <span class="ref-name">main</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -86,9 +87,7 @@ | |||||
| 86 | <main id="main" tabindex="-1"> | 87 | <main id="main" tabindex="-1"> | ||
| 87 | <div class="filter"> | 88 | <div class="filter"> | ||
| 88 | <label for="commit-filter" class="sr">filter</label> | 89 | <label for="commit-filter" class="sr">filter</label> | ||
| 89 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 90 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> |
| 90 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |||
| 91 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |||
| 92 | </div> | 91 | </div> | ||
| 93 | <div class="days"> | 92 | <div class="days"> | ||
| 94 | <section aria-label="2026-03-01"> | 93 | <section aria-label="2026-03-01"> | ||
| @@ -259,7 +258,7 @@ | |||||
| 259 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 258 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 260 | <div class="panel-head"> | 259 | <div class="panel-head"> | ||
| 261 | <label for="refs-filter" class="sr">filter branches and tags</label> | 260 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 262 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 261 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 263 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 262 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 264 | <kbd>esc</kbd> | 263 | <kbd>esc</kbd> | ||
| 265 | </button> | 264 | </button> | ||
| @@ -286,13 +285,7 @@ | |||||
| 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 | <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()"> | ||
| 287 | <div class="panel-head"> | 286 | <div class="panel-head"> | ||
| 288 | <label for="cmd-q" class="sr">go to</label> | 287 | <label for="cmd-q" class="sr">go to</label> | ||
| 289 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 288 | + | <input id="cmd-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)"> |
| 290 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 291 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 292 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 293 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 294 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 295 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 296 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 289 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 297 | <kbd>esc</kbd> | 290 | <kbd>esc</kbd> | ||
| 298 | </button> | 291 | </button> | ||
| @@ -357,67 +350,27 @@ | |||||
| 357 | <kbd>esc</kbd> close</span> | 350 | <kbd>esc</kbd> close</span> | ||
| 358 | </p> | 351 | </p> | ||
| 359 | </dialog> | 352 | </dialog> | ||
| 360 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 353 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 361 | - | if (q('dialog[open]').count) return; | 354 | + | k-k from:body -> ermite.move(-1); |
| 362 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 355 | + | k-top from:body -> ermite.edge(false); |
| 363 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 356 | + | k-bottom from:body -> ermite.edge(true); |
| 364 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 357 | + | k-open from:body -> ermite.open(); |
| 365 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 358 | + | k-esc from:body -> ermite.closeDialogs(); |
| 366 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 359 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 367 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 360 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 368 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 361 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 369 | - | })(1); | 362 | + | k-filter from:body -> ermite.focusFilter(); |
| 370 | - | k-k from:body -> (async (d) => { | 363 | + | k-gh from:body -> ermite.go('.brand'); |
| 371 | - | if (q('dialog[open]').count) return; | 364 | + | k-s from:body -> ermite.tab('s'); |
| 372 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 365 | + | k-t from:body -> ermite.tab('t'); |
| 373 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 366 | + | k-c from:body -> ermite.tab('c'); |
| 374 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 367 | + | k-r from:body -> ermite.tab('r'); |
| 375 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 368 | + | k-m from:body -> ermite.tab('m'); |
| 376 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 369 | + | k-next from:body -> ermite.nextNote(); |
| 377 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 370 | + | k-split from:body -> ermite.layout('split'); |
| 378 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 371 | + | k-unified from:body -> ermite.layout('unified'); |
| 379 | - | })(-1); | 372 | + | k-collapse from:body -> ermite.collapse(); |
| 380 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 373 | + | k-permalink from:body -> ermite.permalink()"> |
| 381 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 382 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 383 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 384 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 385 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 386 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 387 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 388 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 389 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 390 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 391 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 392 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 393 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 394 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 395 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 396 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 397 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 398 | - | k-next from:body -> { | |||
| 399 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 400 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 401 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 402 | - | }; | |||
| 403 | - | k-split from:body -> { | |||
| 404 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 405 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 406 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 407 | - | }; | |||
| 408 | - | k-unified from:body -> { | |||
| 409 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 410 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 411 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 412 | - | }; | |||
| 413 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 414 | - | k-permalink from:body -> { | |||
| 415 | - | let b = document.body.dataset; | |||
| 416 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 417 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 418 | - | navigator.clipboard?.writeText(location.href); | |||
| 419 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 420 | - | }"> | |||
| 421 | </div> | 374 | </div> | ||
| 422 | </body> | 375 | </body> | ||
| 423 | </html> | 376 | </html> | ||
R cmd/ermite/testdata/web/commits-path.html → internal/app/testdata/web/commits-path.html +29 -76 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -89,9 +90,7 @@ | |||||
| 89 | <main id="main" tabindex="-1"> | 90 | <main id="main" tabindex="-1"> | ||
| 90 | <div class="filter"> | 91 | <div class="filter"> | ||
| 91 | <label for="commit-filter" class="sr">filter</label> | 92 | <label for="commit-filter" class="sr">filter</label> | ||
| 92 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 93 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> |
| 93 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |||
| 94 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |||
| 95 | </div> | 94 | </div> | ||
| 96 | <p class="lede dim">history of <code>a.txt</code> at main</p> | 95 | <p class="lede dim">history of <code>a.txt</code> at main</p> | ||
| 97 | <div class="days"> | 96 | <div class="days"> | ||
| @@ -274,7 +273,7 @@ | |||||
| 274 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 273 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 275 | <div class="panel-head"> | 274 | <div class="panel-head"> | ||
| 276 | <label for="refs-filter" class="sr">filter branches and tags</label> | 275 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 277 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 276 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 278 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 277 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 279 | <kbd>esc</kbd> | 278 | <kbd>esc</kbd> | ||
| 280 | </button> | 279 | </button> | ||
| @@ -301,13 +300,7 @@ | |||||
| 301 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 300 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 302 | <div class="panel-head"> | 301 | <div class="panel-head"> | ||
| 303 | <label for="cmd-q" class="sr">go to</label> | 302 | <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="let t = this.value.toLowerCase().trim(); | 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)"> |
| 305 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 306 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 307 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 308 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 309 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 310 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 311 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 304 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 312 | <kbd>esc</kbd> | 305 | <kbd>esc</kbd> | ||
| 313 | </button> | 306 | </button> | ||
| @@ -372,67 +365,27 @@ | |||||
| 372 | <kbd>esc</kbd> close</span> | 365 | <kbd>esc</kbd> close</span> | ||
| 373 | </p> | 366 | </p> | ||
| 374 | </dialog> | 367 | </dialog> | ||
| 375 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 368 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 376 | - | if (q('dialog[open]').count) return; | 369 | + | k-k from:body -> ermite.move(-1); |
| 377 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 370 | + | k-top from:body -> ermite.edge(false); |
| 378 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 371 | + | k-bottom from:body -> ermite.edge(true); |
| 379 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 372 | + | k-open from:body -> ermite.open(); |
| 380 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 373 | + | k-esc from:body -> ermite.closeDialogs(); |
| 381 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 374 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 382 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 375 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 383 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 376 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 384 | - | })(1); | 377 | + | k-filter from:body -> ermite.focusFilter(); |
| 385 | - | k-k from:body -> (async (d) => { | 378 | + | k-gh from:body -> ermite.go('.brand'); |
| 386 | - | if (q('dialog[open]').count) return; | 379 | + | k-s from:body -> ermite.tab('s'); |
| 387 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 380 | + | k-t from:body -> ermite.tab('t'); |
| 388 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 381 | + | k-c from:body -> ermite.tab('c'); |
| 389 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 382 | + | k-r from:body -> ermite.tab('r'); |
| 390 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 383 | + | k-m from:body -> ermite.tab('m'); |
| 391 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 384 | + | k-next from:body -> ermite.nextNote(); |
| 392 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 385 | + | k-split from:body -> ermite.layout('split'); |
| 393 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 386 | + | k-unified from:body -> ermite.layout('unified'); |
| 394 | - | })(-1); | 387 | + | k-collapse from:body -> ermite.collapse(); |
| 395 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 388 | + | k-permalink from:body -> ermite.permalink()"> |
| 396 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 397 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 398 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 399 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 400 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 401 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 402 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 403 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 404 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 405 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 406 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 407 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 408 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 409 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 410 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 411 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 412 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 413 | - | k-next from:body -> { | |||
| 414 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 415 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 416 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 417 | - | }; | |||
| 418 | - | k-split from:body -> { | |||
| 419 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 420 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 421 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 422 | - | }; | |||
| 423 | - | k-unified from:body -> { | |||
| 424 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 425 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 426 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 427 | - | }; | |||
| 428 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 429 | - | k-permalink from:body -> { | |||
| 430 | - | let b = document.body.dataset; | |||
| 431 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 432 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 433 | - | navigator.clipboard?.writeText(location.href); | |||
| 434 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 435 | - | }"> | |||
| 436 | </div> | 389 | </div> | ||
| 437 | </body> | 390 | </body> | ||
| 438 | </html> | 391 | </html> | ||
R cmd/ermite/testdata/web/commits-root.html → internal/app/testdata/web/commits-root.html +32 -77 renamed, 79% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">main</span> | 49 | <span class="ref-name">main</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -86,11 +87,9 @@ | |||||
| 86 | <main id="main" tabindex="-1"> | 87 | <main id="main" tabindex="-1"> | ||
| 87 | <div class="filter"> | 88 | <div class="filter"> | ||
| 88 | <label for="commit-filter" class="sr">filter</label> | 89 | <label for="commit-filter" class="sr">filter</label> | ||
| 89 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 90 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> |
| 90 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |||
| 91 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |||
| 92 | </div> | 91 | </div> | ||
| 93 | - | <div id="commits-head"> | 92 | + | <div id="commits-head" class="live-slot" role="status" data-slot> |
| 94 | </div> | 93 | </div> | ||
| 95 | <div class="days"> | 94 | <div class="days"> | ||
| 96 | <section aria-label="2026-03-26"> | 95 | <section aria-label="2026-03-26"> | ||
| @@ -958,6 +957,8 @@ | |||||
| 958 | </span> | 957 | </span> | ||
| 959 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Along&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | 958 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Along&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | ||
| 960 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 959 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 960 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |||
| 961 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |||
| 961 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 962 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | ||
| 962 | gap -> ermiteLive.reload()"> | 963 | gap -> ermiteLive.reload()"> | ||
| 963 | </span> | 964 | </span> | ||
| @@ -1061,7 +1062,7 @@ | |||||
| 1061 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 1062 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 1062 | <div class="panel-head"> | 1063 | <div class="panel-head"> | ||
| 1063 | <label for="refs-filter" class="sr">filter branches and tags</label> | 1064 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 1064 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 1065 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 1065 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 1066 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 1066 | <kbd>esc</kbd> | 1067 | <kbd>esc</kbd> | ||
| 1067 | </button> | 1068 | </button> | ||
| @@ -1088,13 +1089,7 @@ | |||||
| 1088 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 1089 | <div class="panel-head"> | 1090 | <div class="panel-head"> | ||
| 1090 | <label for="cmd-q" class="sr">go to</label> | 1091 | <label for="cmd-q" class="sr">go to</label> | ||
| 1091 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 1092 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 1093 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 1094 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 1095 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 1096 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 1097 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 1098 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 1093 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 1099 | <kbd>esc</kbd> | 1094 | <kbd>esc</kbd> | ||
| 1100 | </button> | 1095 | </button> | ||
| @@ -1159,67 +1154,27 @@ | |||||
| 1159 | <kbd>esc</kbd> close</span> | 1154 | <kbd>esc</kbd> close</span> | ||
| 1160 | </p> | 1155 | </p> | ||
| 1161 | </dialog> | 1156 | </dialog> | ||
| 1162 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 1157 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 1163 | - | if (q('dialog[open]').count) return; | 1158 | + | k-k from:body -> ermite.move(-1); |
| 1164 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 1159 | + | k-top from:body -> ermite.edge(false); |
| 1165 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 1160 | + | k-bottom from:body -> ermite.edge(true); |
| 1166 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 1161 | + | k-open from:body -> ermite.open(); |
| 1167 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 1162 | + | k-esc from:body -> ermite.closeDialogs(); |
| 1168 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 1163 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 1169 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 1164 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 1170 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 1165 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 1171 | - | })(1); | 1166 | + | k-filter from:body -> ermite.focusFilter(); |
| 1172 | - | k-k from:body -> (async (d) => { | 1167 | + | k-gh from:body -> ermite.go('.brand'); |
| 1173 | - | if (q('dialog[open]').count) return; | 1168 | + | k-s from:body -> ermite.tab('s'); |
| 1174 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 1169 | + | k-t from:body -> ermite.tab('t'); |
| 1175 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 1170 | + | k-c from:body -> ermite.tab('c'); |
| 1176 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 1171 | + | k-r from:body -> ermite.tab('r'); |
| 1177 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 1172 | + | k-m from:body -> ermite.tab('m'); |
| 1178 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 1173 | + | k-next from:body -> ermite.nextNote(); |
| 1179 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 1174 | + | k-split from:body -> ermite.layout('split'); |
| 1180 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 1175 | + | k-unified from:body -> ermite.layout('unified'); |
| 1181 | - | })(-1); | 1176 | + | k-collapse from:body -> ermite.collapse(); |
| 1182 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 1177 | + | k-permalink from:body -> ermite.permalink()"> |
| 1183 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 1184 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 1185 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 1186 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 1187 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 1188 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 1189 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 1190 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 1191 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 1192 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 1193 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 1194 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 1195 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 1196 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 1197 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 1198 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 1199 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 1200 | - | k-next from:body -> { | |||
| 1201 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 1202 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 1203 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 1204 | - | }; | |||
| 1205 | - | k-split from:body -> { | |||
| 1206 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 1207 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 1208 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 1209 | - | }; | |||
| 1210 | - | k-unified from:body -> { | |||
| 1211 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 1212 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 1213 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 1214 | - | }; | |||
| 1215 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 1216 | - | k-permalink from:body -> { | |||
| 1217 | - | let b = document.body.dataset; | |||
| 1218 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 1219 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 1220 | - | navigator.clipboard?.writeText(location.href); | |||
| 1221 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 1222 | - | }"> | |||
| 1223 | </div> | 1178 | </div> | ||
| 1224 | </body> | 1179 | </body> | ||
| 1225 | </html> | 1180 | </html> | ||
R cmd/ermite/testdata/web/commits-small.html → internal/app/testdata/web/commits-small.html +32 -77 renamed, 64% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">main</span> | 49 | <span class="ref-name">main</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -86,11 +87,9 @@ | |||||
| 86 | <main id="main" tabindex="-1"> | 87 | <main id="main" tabindex="-1"> | ||
| 87 | <div class="filter"> | 88 | <div class="filter"> | ||
| 88 | <label for="commit-filter" class="sr">filter</label> | 89 | <label for="commit-filter" class="sr">filter</label> | ||
| 89 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 90 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> |
| 90 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |||
| 91 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |||
| 92 | </div> | 91 | </div> | ||
| 93 | - | <div id="commits-head"> | 92 | + | <div id="commits-head" class="live-slot" role="status" data-slot> |
| 94 | </div> | 93 | </div> | ||
| 95 | <div class="days"> | 94 | <div class="days"> | ||
| 96 | <section aria-label="2026-02-08"> | 95 | <section aria-label="2026-02-08"> | ||
| @@ -303,6 +302,8 @@ | |||||
| 303 | </span> | 302 | </span> | ||
| 304 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Adiffs&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | 303 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Adiffs&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | ||
| 305 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 304 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 305 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |||
| 306 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |||
| 306 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 307 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | ||
| 307 | gap -> ermiteLive.reload()"> | 308 | gap -> ermiteLive.reload()"> | ||
| 308 | </span> | 309 | </span> | ||
| @@ -406,7 +407,7 @@ | |||||
| 406 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 407 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 407 | <div class="panel-head"> | 408 | <div class="panel-head"> | ||
| 408 | <label for="refs-filter" class="sr">filter branches and tags</label> | 409 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 409 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 410 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 410 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 411 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 411 | <kbd>esc</kbd> | 412 | <kbd>esc</kbd> | ||
| 412 | </button> | 413 | </button> | ||
| @@ -441,13 +442,7 @@ | |||||
| 441 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 442 | <div class="panel-head"> | 443 | <div class="panel-head"> | ||
| 443 | <label for="cmd-q" class="sr">go to</label> | 444 | <label for="cmd-q" class="sr">go to</label> | ||
| 444 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 445 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 446 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 447 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 448 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 449 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 450 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 451 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 446 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 452 | <kbd>esc</kbd> | 447 | <kbd>esc</kbd> | ||
| 453 | </button> | 448 | </button> | ||
| @@ -524,67 +519,27 @@ | |||||
| 524 | <kbd>esc</kbd> close</span> | 519 | <kbd>esc</kbd> close</span> | ||
| 525 | </p> | 520 | </p> | ||
| 526 | </dialog> | 521 | </dialog> | ||
| 527 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 522 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 528 | - | if (q('dialog[open]').count) return; | 523 | + | k-k from:body -> ermite.move(-1); |
| 529 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 524 | + | k-top from:body -> ermite.edge(false); |
| 530 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 525 | + | k-bottom from:body -> ermite.edge(true); |
| 531 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 526 | + | k-open from:body -> ermite.open(); |
| 532 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 527 | + | k-esc from:body -> ermite.closeDialogs(); |
| 533 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 528 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 534 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 529 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 535 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 530 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 536 | - | })(1); | 531 | + | k-filter from:body -> ermite.focusFilter(); |
| 537 | - | k-k from:body -> (async (d) => { | 532 | + | k-gh from:body -> ermite.go('.brand'); |
| 538 | - | if (q('dialog[open]').count) return; | 533 | + | k-s from:body -> ermite.tab('s'); |
| 539 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 534 | + | k-t from:body -> ermite.tab('t'); |
| 540 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 535 | + | k-c from:body -> ermite.tab('c'); |
| 541 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 536 | + | k-r from:body -> ermite.tab('r'); |
| 542 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 537 | + | k-m from:body -> ermite.tab('m'); |
| 543 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 538 | + | k-next from:body -> ermite.nextNote(); |
| 544 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 539 | + | k-split from:body -> ermite.layout('split'); |
| 545 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 540 | + | k-unified from:body -> ermite.layout('unified'); |
| 546 | - | })(-1); | 541 | + | k-collapse from:body -> ermite.collapse(); |
| 547 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 542 | + | k-permalink from:body -> ermite.permalink()"> |
| 548 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 549 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 550 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 551 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 552 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 553 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 554 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 555 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 556 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 557 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 558 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 559 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 560 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 561 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 562 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 563 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 564 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 565 | - | k-next from:body -> { | |||
| 566 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 567 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 568 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 569 | - | }; | |||
| 570 | - | k-split from:body -> { | |||
| 571 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 572 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 573 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 574 | - | }; | |||
| 575 | - | k-unified from:body -> { | |||
| 576 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 577 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 578 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 579 | - | }; | |||
| 580 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 581 | - | k-permalink from:body -> { | |||
| 582 | - | let b = document.body.dataset; | |||
| 583 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 584 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 585 | - | navigator.clipboard?.writeText(location.href); | |||
| 586 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 587 | - | }"> | |||
| 588 | </div> | 543 | </div> | ||
| 589 | </body> | 544 | </body> | ||
| 590 | </html> | 545 | </html> | ||
A internal/app/testdata/web/empty.html +239 -0
| @@ -0,0 +1,239 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>quiet · example.test</title> | |||
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 13 | + | </script> | |||
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 15 | + | </script> | |||
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 17 | + | </script> | |||
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 19 | + | </script> | |||
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 21 | + | </script> | |||
| 22 | + | </head> | |||
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 24 | + | <a class="skip" href="#main">skip to content</a> | |||
| 25 | + | <header class="hdr"> | |||
| 26 | + | <div class="hdr-top"> | |||
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |||
| 29 | + | </span> | |||
| 30 | + | <span class="brand-name">example.test</span> | |||
| 31 | + | </a> | |||
| 32 | + | <nav class="crumbs" aria-label="Path"> | |||
| 33 | + | <ol> | |||
| 34 | + | <li> | |||
| 35 | + | <a href="/quiet" aria-current="page">quiet</a> | |||
| 36 | + | </li> | |||
| 37 | + | </ol> | |||
| 38 | + | </nav> | |||
| 39 | + | <div class="hdr-acts"> | |||
| 40 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |||
| 41 | + | <span class="ico i-search" aria-hidden="true"> | |||
| 42 | + | </span> | |||
| 43 | + | <span class="search-label">Go to…</span> | |||
| 44 | + | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 45 | + | </button> | |||
| 46 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |||
| 47 | + | </div> | |||
| 48 | + | </div> | |||
| 49 | + | </header> | |||
| 50 | + | <div class="work"> | |||
| 51 | + | <main id="main" tabindex="-1"> | |||
| 52 | + | <section class="state" aria-labelledby="state-title"> | |||
| 53 | + | <p class="eyebrow">empty</p> | |||
| 54 | + | <h1 id="state-title">quiet is empty</h1> | |||
| 55 | + | <p class="dim">no commits yet. push a branch to create one.</p> | |||
| 56 | + | <div class="clone" id="clone-quiet"> | |||
| 57 | + | <div class="clone-line"> | |||
| 58 | + | <span class="mute" aria-hidden="true">$ </span> | |||
| 59 | + | <code>git remote add origin http://example.test/quiet.git</code> | |||
| 60 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="ermite.copy(this)">Copy</button> | |||
| 61 | + | </div> | |||
| 62 | + | <div class="clone-line"> | |||
| 63 | + | <span class="mute" aria-hidden="true">$ </span> | |||
| 64 | + | <code>git push -u origin main</code> | |||
| 65 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="ermite.copy(this)">Copy</button> | |||
| 66 | + | </div> | |||
| 67 | + | </div> | |||
| 68 | + | <p class="state-back"> | |||
| 69 | + | <a href="/">home</a> | |||
| 70 | + | </p> | |||
| 71 | + | </section> | |||
| 72 | + | </main> | |||
| 73 | + | </div> | |||
| 74 | + | <footer class="foot"> | |||
| 75 | + | <span>ermite devel</span> | |||
| 76 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 77 | + | <span class="foot-keys"> | |||
| 78 | + | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 79 | + | </footer> | |||
| 80 | + | <div class="hint" role="status" aria-live="polite"> | |||
| 81 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 82 | + | </span> | |||
| 83 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 84 | + | </span> | |||
| 85 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 86 | + | </span> | |||
| 87 | + | </div> | |||
| 88 | + | <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()"> | |||
| 89 | + | <div class="panel-head"> | |||
| 90 | + | <h2>Keyboard shortcuts</h2> | |||
| 91 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 92 | + | <kbd>esc</kbd> close</button> | |||
| 93 | + | </div> | |||
| 94 | + | <dl class="keys"> | |||
| 95 | + | <div> | |||
| 96 | + | <dt> | |||
| 97 | + | <kbd>ctrl k</kbd> | |||
| 98 | + | <kbd>:</kbd> | |||
| 99 | + | </dt> | |||
| 100 | + | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 101 | + | </div> | |||
| 102 | + | <div> | |||
| 103 | + | <dt> | |||
| 104 | + | <kbd>?</kbd> | |||
| 105 | + | </dt> | |||
| 106 | + | <dd>this list</dd> | |||
| 107 | + | </div> | |||
| 108 | + | <div> | |||
| 109 | + | <dt> | |||
| 110 | + | <kbd>j</kbd> | |||
| 111 | + | <kbd>k</kbd> | |||
| 112 | + | </dt> | |||
| 113 | + | <dd>move the row cursor down / up</dd> | |||
| 114 | + | </div> | |||
| 115 | + | <div> | |||
| 116 | + | <dt> | |||
| 117 | + | <kbd>enter</kbd> | |||
| 118 | + | </dt> | |||
| 119 | + | <dd>open the row under the cursor</dd> | |||
| 120 | + | </div> | |||
| 121 | + | <div> | |||
| 122 | + | <dt> | |||
| 123 | + | <kbd>g g</kbd> | |||
| 124 | + | <kbd>G</kbd> | |||
| 125 | + | </dt> | |||
| 126 | + | <dd>first / last row</dd> | |||
| 127 | + | </div> | |||
| 128 | + | <div> | |||
| 129 | + | <dt> | |||
| 130 | + | <kbd>/</kbd> | |||
| 131 | + | </dt> | |||
| 132 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 133 | + | </div> | |||
| 134 | + | <div> | |||
| 135 | + | <dt> | |||
| 136 | + | <kbd>b</kbd> | |||
| 137 | + | </dt> | |||
| 138 | + | <dd>switch branch or tag</dd> | |||
| 139 | + | </div> | |||
| 140 | + | <div> | |||
| 141 | + | <dt> | |||
| 142 | + | <kbd>s</kbd> | |||
| 143 | + | <kbd>t</kbd> | |||
| 144 | + | <kbd>c</kbd> | |||
| 145 | + | <kbd>r</kbd> | |||
| 146 | + | <kbd>m</kbd> | |||
| 147 | + | </dt> | |||
| 148 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 149 | + | </div> | |||
| 150 | + | <div> | |||
| 151 | + | <dt> | |||
| 152 | + | <kbd>g h</kbd> | |||
| 153 | + | </dt> | |||
| 154 | + | <dd>home</dd> | |||
| 155 | + | </div> | |||
| 156 | + | <div> | |||
| 157 | + | <dt> | |||
| 158 | + | <kbd>y</kbd> | |||
| 159 | + | </dt> | |||
| 160 | + | <dd>copy a permalink pinned to the commit</dd> | |||
| 161 | + | </div> | |||
| 162 | + | <div> | |||
| 163 | + | <dt> | |||
| 164 | + | <kbd>d</kbd> | |||
| 165 | + | <kbd>u</kbd> | |||
| 166 | + | <kbd>x</kbd> | |||
| 167 | + | </dt> | |||
| 168 | + | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 169 | + | </div> | |||
| 170 | + | <div> | |||
| 171 | + | <dt> | |||
| 172 | + | <kbd>n</kbd> | |||
| 173 | + | </dt> | |||
| 174 | + | <dd>merge request: next note in the diff</dd> | |||
| 175 | + | </div> | |||
| 176 | + | <div> | |||
| 177 | + | <dt> | |||
| 178 | + | <kbd>esc</kbd> | |||
| 179 | + | </dt> | |||
| 180 | + | <dd>close a dialog, leave a field</dd> | |||
| 181 | + | </div> | |||
| 182 | + | </dl> | |||
| 183 | + | </dialog> | |||
| 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()"> | |||
| 185 | + | <div class="panel-head"> | |||
| 186 | + | <label for="cmd-q" class="sr">go to</label> | |||
| 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)"> | |||
| 188 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 189 | + | <kbd>esc</kbd> | |||
| 190 | + | </button> | |||
| 191 | + | </div> | |||
| 192 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 193 | + | <li data-k="go home" data-cursor> | |||
| 194 | + | <a href="/" tabindex="-1"> | |||
| 195 | + | <span class="lbl">home</span> | |||
| 196 | + | <span class="k">go</span> | |||
| 197 | + | </a> | |||
| 198 | + | </li> | |||
| 199 | + | <li data-k="repo demo"> | |||
| 200 | + | <a href="/demo" tabindex="-1"> | |||
| 201 | + | <span class="lbl">demo</span> | |||
| 202 | + | <span class="k">repo</span> | |||
| 203 | + | </a> | |||
| 204 | + | </li> | |||
| 205 | + | </ul> | |||
| 206 | + | <p class="panel-foot"> | |||
| 207 | + | <span> | |||
| 208 | + | <kbd>↑</kbd> | |||
| 209 | + | <kbd>↓</kbd> move</span> | |||
| 210 | + | <span> | |||
| 211 | + | <kbd>enter</kbd> open</span> | |||
| 212 | + | <span> | |||
| 213 | + | <kbd>esc</kbd> close</span> | |||
| 214 | + | </p> | |||
| 215 | + | </dialog> | |||
| 216 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |||
| 217 | + | k-k from:body -> ermite.move(-1); | |||
| 218 | + | k-top from:body -> ermite.edge(false); | |||
| 219 | + | k-bottom from:body -> ermite.edge(true); | |||
| 220 | + | k-open from:body -> ermite.open(); | |||
| 221 | + | k-esc from:body -> ermite.closeDialogs(); | |||
| 222 | + | k-help from:body -> ermite.dialog('panel-help'); | |||
| 223 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |||
| 224 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |||
| 225 | + | k-filter from:body -> ermite.focusFilter(); | |||
| 226 | + | k-gh from:body -> ermite.go('.brand'); | |||
| 227 | + | k-s from:body -> ermite.tab('s'); | |||
| 228 | + | k-t from:body -> ermite.tab('t'); | |||
| 229 | + | k-c from:body -> ermite.tab('c'); | |||
| 230 | + | k-r from:body -> ermite.tab('r'); | |||
| 231 | + | k-m from:body -> ermite.tab('m'); | |||
| 232 | + | k-next from:body -> ermite.nextNote(); | |||
| 233 | + | k-split from:body -> ermite.layout('split'); | |||
| 234 | + | k-unified from:body -> ermite.layout('unified'); | |||
| 235 | + | k-collapse from:body -> ermite.collapse(); | |||
| 236 | + | k-permalink from:body -> ermite.permalink()"> | |||
| 237 | + | </div> | |||
| 238 | + | </body> | |||
| 239 | + | </html> | |||
A internal/app/testdata/web/home.html +277 -0
| @@ -0,0 +1,277 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>example.test</title> | |||
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 13 | + | </script> | |||
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 15 | + | </script> | |||
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 17 | + | </script> | |||
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 19 | + | </script> | |||
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 21 | + | </script> | |||
| 22 | + | </head> | |||
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 24 | + | <a class="skip" href="#main">skip to content</a> | |||
| 25 | + | <header class="hdr"> | |||
| 26 | + | <div class="hdr-top"> | |||
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |||
| 29 | + | </span> | |||
| 30 | + | <span class="brand-name">example.test</span> | |||
| 31 | + | </a> | |||
| 32 | + | <div class="hdr-acts"> | |||
| 33 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |||
| 34 | + | <span class="ico i-search" aria-hidden="true"> | |||
| 35 | + | </span> | |||
| 36 | + | <span class="search-label">Go to…</span> | |||
| 37 | + | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 38 | + | </button> | |||
| 39 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |||
| 40 | + | </div> | |||
| 41 | + | </div> | |||
| 42 | + | </header> | |||
| 43 | + | <div class="work"> | |||
| 44 | + | <main id="main" tabindex="-1"> | |||
| 45 | + | <div class="filter"> | |||
| 46 | + | <label for="home-filter" class="sr">filter</label> | |||
| 47 | + | <input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |||
| 48 | + | </div> | |||
| 49 | + | <table class="list home" aria-label="Repositories"> | |||
| 50 | + | <thead> | |||
| 51 | + | <tr> | |||
| 52 | + | <th class="cur"> | |||
| 53 | + | </th> | |||
| 54 | + | <th class="c-name" aria-sort="none"> | |||
| 55 | + | <a href="/?sort=name&dir=asc">name </a> | |||
| 56 | + | </th> | |||
| 57 | + | <th class="c-desc" aria-sort="none"> | |||
| 58 | + | <a href="/?sort=desc&dir=asc">description </a> | |||
| 59 | + | </th> | |||
| 60 | + | <th class="c-when" aria-sort="descending"> | |||
| 61 | + | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |||
| 62 | + | </a> | |||
| 63 | + | </th> | |||
| 64 | + | <th class="c-ref" aria-sort="none"> | |||
| 65 | + | <a href="/?sort=branch&dir=asc">branch </a> | |||
| 66 | + | </th> | |||
| 67 | + | </tr> | |||
| 68 | + | </thead> | |||
| 69 | + | <tbody id="repo-rows" data-rows> | |||
| 70 | + | <tr id="repo-demo" data-cursor> | |||
| 71 | + | <td class="cur" aria-hidden="true"> | |||
| 72 | + | </td> | |||
| 73 | + | <td class="c-name"> | |||
| 74 | + | <a href="/demo">demo</a> | |||
| 75 | + | </td> | |||
| 76 | + | <td class="c-desc dim">A demo repository</td> | |||
| 77 | + | <td class="c-when nowrap"> | |||
| 78 | + | <time datetime="STAMP">AGO</time> | |||
| 79 | + | </td> | |||
| 80 | + | <td class="c-ref">main</td> | |||
| 81 | + | </tr> | |||
| 82 | + | <tr id="repo-quiet"> | |||
| 83 | + | <td class="cur" aria-hidden="true"> | |||
| 84 | + | </td> | |||
| 85 | + | <td class="c-name"> | |||
| 86 | + | <a href="/quiet">quiet</a> | |||
| 87 | + | </td> | |||
| 88 | + | <td class="c-desc dim"> | |||
| 89 | + | </td> | |||
| 90 | + | <td class="c-when nowrap"> | |||
| 91 | + | <time>never</time> | |||
| 92 | + | </td> | |||
| 93 | + | <td class="c-ref">main</td> | |||
| 94 | + | </tr> | |||
| 95 | + | </tbody> | |||
| 96 | + | </table> | |||
| 97 | + | </main> | |||
| 98 | + | </div> | |||
| 99 | + | <footer class="foot"> | |||
| 100 | + | <span>ermite devel</span> | |||
| 101 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 102 | + | <span class="foot-keys"> | |||
| 103 | + | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 104 | + | </footer> | |||
| 105 | + | <div class="hint" role="status" aria-live="polite"> | |||
| 106 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 107 | + | </span> | |||
| 108 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 109 | + | </span> | |||
| 110 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 111 | + | </span> | |||
| 112 | + | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |||
| 113 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 114 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |||
| 115 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |||
| 116 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |||
| 117 | + | gap -> ermiteLive.reload()"> | |||
| 118 | + | </span> | |||
| 119 | + | </div> | |||
| 120 | + | <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()"> | |||
| 121 | + | <div class="panel-head"> | |||
| 122 | + | <h2>Keyboard shortcuts</h2> | |||
| 123 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 124 | + | <kbd>esc</kbd> close</button> | |||
| 125 | + | </div> | |||
| 126 | + | <dl class="keys"> | |||
| 127 | + | <div> | |||
| 128 | + | <dt> | |||
| 129 | + | <kbd>ctrl k</kbd> | |||
| 130 | + | <kbd>:</kbd> | |||
| 131 | + | </dt> | |||
| 132 | + | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 133 | + | </div> | |||
| 134 | + | <div> | |||
| 135 | + | <dt> | |||
| 136 | + | <kbd>?</kbd> | |||
| 137 | + | </dt> | |||
| 138 | + | <dd>this list</dd> | |||
| 139 | + | </div> | |||
| 140 | + | <div> | |||
| 141 | + | <dt> | |||
| 142 | + | <kbd>j</kbd> | |||
| 143 | + | <kbd>k</kbd> | |||
| 144 | + | </dt> | |||
| 145 | + | <dd>move the row cursor down / up</dd> | |||
| 146 | + | </div> | |||
| 147 | + | <div> | |||
| 148 | + | <dt> | |||
| 149 | + | <kbd>enter</kbd> | |||
| 150 | + | </dt> | |||
| 151 | + | <dd>open the row under the cursor</dd> | |||
| 152 | + | </div> | |||
| 153 | + | <div> | |||
| 154 | + | <dt> | |||
| 155 | + | <kbd>g g</kbd> | |||
| 156 | + | <kbd>G</kbd> | |||
| 157 | + | </dt> | |||
| 158 | + | <dd>first / last row</dd> | |||
| 159 | + | </div> | |||
| 160 | + | <div> | |||
| 161 | + | <dt> | |||
| 162 | + | <kbd>/</kbd> | |||
| 163 | + | </dt> | |||
| 164 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 165 | + | </div> | |||
| 166 | + | <div> | |||
| 167 | + | <dt> | |||
| 168 | + | <kbd>b</kbd> | |||
| 169 | + | </dt> | |||
| 170 | + | <dd>switch branch or tag</dd> | |||
| 171 | + | </div> | |||
| 172 | + | <div> | |||
| 173 | + | <dt> | |||
| 174 | + | <kbd>s</kbd> | |||
| 175 | + | <kbd>t</kbd> | |||
| 176 | + | <kbd>c</kbd> | |||
| 177 | + | <kbd>r</kbd> | |||
| 178 | + | <kbd>m</kbd> | |||
| 179 | + | </dt> | |||
| 180 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 181 | + | </div> | |||
| 182 | + | <div> | |||
| 183 | + | <dt> | |||
| 184 | + | <kbd>g h</kbd> | |||
| 185 | + | </dt> | |||
| 186 | + | <dd>home</dd> | |||
| 187 | + | </div> | |||
| 188 | + | <div> | |||
| 189 | + | <dt> | |||
| 190 | + | <kbd>y</kbd> | |||
| 191 | + | </dt> | |||
| 192 | + | <dd>copy a permalink pinned to the commit</dd> | |||
| 193 | + | </div> | |||
| 194 | + | <div> | |||
| 195 | + | <dt> | |||
| 196 | + | <kbd>d</kbd> | |||
| 197 | + | <kbd>u</kbd> | |||
| 198 | + | <kbd>x</kbd> | |||
| 199 | + | </dt> | |||
| 200 | + | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 201 | + | </div> | |||
| 202 | + | <div> | |||
| 203 | + | <dt> | |||
| 204 | + | <kbd>n</kbd> | |||
| 205 | + | </dt> | |||
| 206 | + | <dd>merge request: next note in the diff</dd> | |||
| 207 | + | </div> | |||
| 208 | + | <div> | |||
| 209 | + | <dt> | |||
| 210 | + | <kbd>esc</kbd> | |||
| 211 | + | </dt> | |||
| 212 | + | <dd>close a dialog, leave a field</dd> | |||
| 213 | + | </div> | |||
| 214 | + | </dl> | |||
| 215 | + | </dialog> | |||
| 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()"> | |||
| 217 | + | <div class="panel-head"> | |||
| 218 | + | <label for="cmd-q" class="sr">go to</label> | |||
| 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)"> | |||
| 220 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 221 | + | <kbd>esc</kbd> | |||
| 222 | + | </button> | |||
| 223 | + | </div> | |||
| 224 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 225 | + | <li data-k="go home" data-cursor> | |||
| 226 | + | <a href="/" tabindex="-1"> | |||
| 227 | + | <span class="lbl">home</span> | |||
| 228 | + | <span class="k">go</span> | |||
| 229 | + | </a> | |||
| 230 | + | </li> | |||
| 231 | + | <li data-k="repo demo"> | |||
| 232 | + | <a href="/demo" tabindex="-1"> | |||
| 233 | + | <span class="lbl">demo</span> | |||
| 234 | + | <span class="k">repo</span> | |||
| 235 | + | </a> | |||
| 236 | + | </li> | |||
| 237 | + | <li data-k="repo quiet"> | |||
| 238 | + | <a href="/quiet" tabindex="-1"> | |||
| 239 | + | <span class="lbl">quiet</span> | |||
| 240 | + | <span class="k">repo</span> | |||
| 241 | + | </a> | |||
| 242 | + | </li> | |||
| 243 | + | </ul> | |||
| 244 | + | <p class="panel-foot"> | |||
| 245 | + | <span> | |||
| 246 | + | <kbd>↑</kbd> | |||
| 247 | + | <kbd>↓</kbd> move</span> | |||
| 248 | + | <span> | |||
| 249 | + | <kbd>enter</kbd> open</span> | |||
| 250 | + | <span> | |||
| 251 | + | <kbd>esc</kbd> close</span> | |||
| 252 | + | </p> | |||
| 253 | + | </dialog> | |||
| 254 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |||
| 255 | + | k-k from:body -> ermite.move(-1); | |||
| 256 | + | k-top from:body -> ermite.edge(false); | |||
| 257 | + | k-bottom from:body -> ermite.edge(true); | |||
| 258 | + | k-open from:body -> ermite.open(); | |||
| 259 | + | k-esc from:body -> ermite.closeDialogs(); | |||
| 260 | + | k-help from:body -> ermite.dialog('panel-help'); | |||
| 261 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |||
| 262 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |||
| 263 | + | k-filter from:body -> ermite.focusFilter(); | |||
| 264 | + | k-gh from:body -> ermite.go('.brand'); | |||
| 265 | + | k-s from:body -> ermite.tab('s'); | |||
| 266 | + | k-t from:body -> ermite.tab('t'); | |||
| 267 | + | k-c from:body -> ermite.tab('c'); | |||
| 268 | + | k-r from:body -> ermite.tab('r'); | |||
| 269 | + | k-m from:body -> ermite.tab('m'); | |||
| 270 | + | k-next from:body -> ermite.nextNote(); | |||
| 271 | + | k-split from:body -> ermite.layout('split'); | |||
| 272 | + | k-unified from:body -> ermite.layout('unified'); | |||
| 273 | + | k-collapse from:body -> ermite.collapse(); | |||
| 274 | + | k-permalink from:body -> ermite.permalink()"> | |||
| 275 | + | </div> | |||
| 276 | + | </body> | |||
| 277 | + | </html> | |||
A internal/app/testdata/web/not-found-page.html +222 -0
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>404 · example.test</title> | |||
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 13 | + | </script> | |||
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 15 | + | </script> | |||
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 17 | + | </script> | |||
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 19 | + | </script> | |||
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 21 | + | </script> | |||
| 22 | + | </head> | |||
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 24 | + | <a class="skip" href="#main">skip to content</a> | |||
| 25 | + | <header class="hdr"> | |||
| 26 | + | <div class="hdr-top"> | |||
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |||
| 29 | + | </span> | |||
| 30 | + | <span class="brand-name">example.test</span> | |||
| 31 | + | </a> | |||
| 32 | + | <nav class="crumbs" aria-label="Path"> | |||
| 33 | + | <ol> | |||
| 34 | + | <li> | |||
| 35 | + | <span aria-current="page">not found</span> | |||
| 36 | + | </li> | |||
| 37 | + | </ol> | |||
| 38 | + | </nav> | |||
| 39 | + | <div class="hdr-acts"> | |||
| 40 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |||
| 41 | + | <span class="ico i-search" aria-hidden="true"> | |||
| 42 | + | </span> | |||
| 43 | + | <span class="search-label">Go to…</span> | |||
| 44 | + | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 45 | + | </button> | |||
| 46 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |||
| 47 | + | </div> | |||
| 48 | + | </div> | |||
| 49 | + | </header> | |||
| 50 | + | <div class="work"> | |||
| 51 | + | <main id="main" tabindex="-1"> | |||
| 52 | + | <section class="state" aria-labelledby="state-title"> | |||
| 53 | + | <p class="eyebrow">404</p> | |||
| 54 | + | <h1 id="state-title">not found</h1> | |||
| 55 | + | <p class="dim">no such page, ref or path.</p> | |||
| 56 | + | <p class="dim">check the URL, or start from the repo list.</p> | |||
| 57 | + | <p class="state-back"> | |||
| 58 | + | <a href="/">home</a> | |||
| 59 | + | </p> | |||
| 60 | + | </section> | |||
| 61 | + | </main> | |||
| 62 | + | </div> | |||
| 63 | + | <footer class="foot"> | |||
| 64 | + | <span>ermite devel</span> | |||
| 65 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 66 | + | <span class="foot-keys"> | |||
| 67 | + | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 68 | + | </footer> | |||
| 69 | + | <div class="hint" role="status" aria-live="polite"> | |||
| 70 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 71 | + | </span> | |||
| 72 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 73 | + | </span> | |||
| 74 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 75 | + | </span> | |||
| 76 | + | </div> | |||
| 77 | + | <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()"> | |||
| 78 | + | <div class="panel-head"> | |||
| 79 | + | <h2>Keyboard shortcuts</h2> | |||
| 80 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 81 | + | <kbd>esc</kbd> close</button> | |||
| 82 | + | </div> | |||
| 83 | + | <dl class="keys"> | |||
| 84 | + | <div> | |||
| 85 | + | <dt> | |||
| 86 | + | <kbd>ctrl k</kbd> | |||
| 87 | + | <kbd>:</kbd> | |||
| 88 | + | </dt> | |||
| 89 | + | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 90 | + | </div> | |||
| 91 | + | <div> | |||
| 92 | + | <dt> | |||
| 93 | + | <kbd>?</kbd> | |||
| 94 | + | </dt> | |||
| 95 | + | <dd>this list</dd> | |||
| 96 | + | </div> | |||
| 97 | + | <div> | |||
| 98 | + | <dt> | |||
| 99 | + | <kbd>j</kbd> | |||
| 100 | + | <kbd>k</kbd> | |||
| 101 | + | </dt> | |||
| 102 | + | <dd>move the row cursor down / up</dd> | |||
| 103 | + | </div> | |||
| 104 | + | <div> | |||
| 105 | + | <dt> | |||
| 106 | + | <kbd>enter</kbd> | |||
| 107 | + | </dt> | |||
| 108 | + | <dd>open the row under the cursor</dd> | |||
| 109 | + | </div> | |||
| 110 | + | <div> | |||
| 111 | + | <dt> | |||
| 112 | + | <kbd>g g</kbd> | |||
| 113 | + | <kbd>G</kbd> | |||
| 114 | + | </dt> | |||
| 115 | + | <dd>first / last row</dd> | |||
| 116 | + | </div> | |||
| 117 | + | <div> | |||
| 118 | + | <dt> | |||
| 119 | + | <kbd>/</kbd> | |||
| 120 | + | </dt> | |||
| 121 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 122 | + | </div> | |||
| 123 | + | <div> | |||
| 124 | + | <dt> | |||
| 125 | + | <kbd>b</kbd> | |||
| 126 | + | </dt> | |||
| 127 | + | <dd>switch branch or tag</dd> | |||
| 128 | + | </div> | |||
| 129 | + | <div> | |||
| 130 | + | <dt> | |||
| 131 | + | <kbd>s</kbd> | |||
| 132 | + | <kbd>t</kbd> | |||
| 133 | + | <kbd>c</kbd> | |||
| 134 | + | <kbd>r</kbd> | |||
| 135 | + | <kbd>m</kbd> | |||
| 136 | + | </dt> | |||
| 137 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 138 | + | </div> | |||
| 139 | + | <div> | |||
| 140 | + | <dt> | |||
| 141 | + | <kbd>g h</kbd> | |||
| 142 | + | </dt> | |||
| 143 | + | <dd>home</dd> | |||
| 144 | + | </div> | |||
| 145 | + | <div> | |||
| 146 | + | <dt> | |||
| 147 | + | <kbd>y</kbd> | |||
| 148 | + | </dt> | |||
| 149 | + | <dd>copy a permalink pinned to the commit</dd> | |||
| 150 | + | </div> | |||
| 151 | + | <div> | |||
| 152 | + | <dt> | |||
| 153 | + | <kbd>d</kbd> | |||
| 154 | + | <kbd>u</kbd> | |||
| 155 | + | <kbd>x</kbd> | |||
| 156 | + | </dt> | |||
| 157 | + | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 158 | + | </div> | |||
| 159 | + | <div> | |||
| 160 | + | <dt> | |||
| 161 | + | <kbd>n</kbd> | |||
| 162 | + | </dt> | |||
| 163 | + | <dd>merge request: next note in the diff</dd> | |||
| 164 | + | </div> | |||
| 165 | + | <div> | |||
| 166 | + | <dt> | |||
| 167 | + | <kbd>esc</kbd> | |||
| 168 | + | </dt> | |||
| 169 | + | <dd>close a dialog, leave a field</dd> | |||
| 170 | + | </div> | |||
| 171 | + | </dl> | |||
| 172 | + | </dialog> | |||
| 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()"> | |||
| 174 | + | <div class="panel-head"> | |||
| 175 | + | <label for="cmd-q" class="sr">go to</label> | |||
| 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)"> | |||
| 177 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 178 | + | <kbd>esc</kbd> | |||
| 179 | + | </button> | |||
| 180 | + | </div> | |||
| 181 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 182 | + | <li data-k="go home" data-cursor> | |||
| 183 | + | <a href="/" tabindex="-1"> | |||
| 184 | + | <span class="lbl">home</span> | |||
| 185 | + | <span class="k">go</span> | |||
| 186 | + | </a> | |||
| 187 | + | </li> | |||
| 188 | + | </ul> | |||
| 189 | + | <p class="panel-foot"> | |||
| 190 | + | <span> | |||
| 191 | + | <kbd>↑</kbd> | |||
| 192 | + | <kbd>↓</kbd> move</span> | |||
| 193 | + | <span> | |||
| 194 | + | <kbd>enter</kbd> open</span> | |||
| 195 | + | <span> | |||
| 196 | + | <kbd>esc</kbd> close</span> | |||
| 197 | + | </p> | |||
| 198 | + | </dialog> | |||
| 199 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |||
| 200 | + | k-k from:body -> ermite.move(-1); | |||
| 201 | + | k-top from:body -> ermite.edge(false); | |||
| 202 | + | k-bottom from:body -> ermite.edge(true); | |||
| 203 | + | k-open from:body -> ermite.open(); | |||
| 204 | + | k-esc from:body -> ermite.closeDialogs(); | |||
| 205 | + | k-help from:body -> ermite.dialog('panel-help'); | |||
| 206 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |||
| 207 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |||
| 208 | + | k-filter from:body -> ermite.focusFilter(); | |||
| 209 | + | k-gh from:body -> ermite.go('.brand'); | |||
| 210 | + | k-s from:body -> ermite.tab('s'); | |||
| 211 | + | k-t from:body -> ermite.tab('t'); | |||
| 212 | + | k-c from:body -> ermite.tab('c'); | |||
| 213 | + | k-r from:body -> ermite.tab('r'); | |||
| 214 | + | k-m from:body -> ermite.tab('m'); | |||
| 215 | + | k-next from:body -> ermite.nextNote(); | |||
| 216 | + | k-split from:body -> ermite.layout('split'); | |||
| 217 | + | k-unified from:body -> ermite.layout('unified'); | |||
| 218 | + | k-collapse from:body -> ermite.collapse(); | |||
| 219 | + | k-permalink from:body -> ermite.permalink()"> | |||
| 220 | + | </div> | |||
| 221 | + | </body> | |||
| 222 | + | </html> | |||
A internal/app/testdata/web/not-found.html +222 -0
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>404 · example.test</title> | |||
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |||
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |||
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 13 | + | </script> | |||
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 15 | + | </script> | |||
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 17 | + | </script> | |||
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 19 | + | </script> | |||
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 21 | + | </script> | |||
| 22 | + | </head> | |||
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |||
| 24 | + | <a class="skip" href="#main">skip to content</a> | |||
| 25 | + | <header class="hdr"> | |||
| 26 | + | <div class="hdr-top"> | |||
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |||
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |||
| 29 | + | </span> | |||
| 30 | + | <span class="brand-name">example.test</span> | |||
| 31 | + | </a> | |||
| 32 | + | <nav class="crumbs" aria-label="Path"> | |||
| 33 | + | <ol> | |||
| 34 | + | <li> | |||
| 35 | + | <span aria-current="page">not found</span> | |||
| 36 | + | </li> | |||
| 37 | + | </ol> | |||
| 38 | + | </nav> | |||
| 39 | + | <div class="hdr-acts"> | |||
| 40 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |||
| 41 | + | <span class="ico i-search" aria-hidden="true"> | |||
| 42 | + | </span> | |||
| 43 | + | <span class="search-label">Go to…</span> | |||
| 44 | + | <kbd aria-hidden="true">ctrl k</kbd> | |||
| 45 | + | </button> | |||
| 46 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |||
| 47 | + | </div> | |||
| 48 | + | </div> | |||
| 49 | + | </header> | |||
| 50 | + | <div class="work"> | |||
| 51 | + | <main id="main" tabindex="-1"> | |||
| 52 | + | <section class="state" aria-labelledby="state-title"> | |||
| 53 | + | <p class="eyebrow">404</p> | |||
| 54 | + | <h1 id="state-title">not found</h1> | |||
| 55 | + | <p class="dim">no such page, ref or path.</p> | |||
| 56 | + | <p class="dim">check the URL, or start from the repo list.</p> | |||
| 57 | + | <p class="state-back"> | |||
| 58 | + | <a href="/">home</a> | |||
| 59 | + | </p> | |||
| 60 | + | </section> | |||
| 61 | + | </main> | |||
| 62 | + | </div> | |||
| 63 | + | <footer class="foot"> | |||
| 64 | + | <span>ermite devel</span> | |||
| 65 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 66 | + | <span class="foot-keys"> | |||
| 67 | + | <kbd>?</kbd> keyboard shortcuts</span> | |||
| 68 | + | </footer> | |||
| 69 | + | <div class="hint" role="status" aria-live="polite"> | |||
| 70 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |||
| 71 | + | </span> | |||
| 72 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |||
| 73 | + | </span> | |||
| 74 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |||
| 75 | + | </span> | |||
| 76 | + | </div> | |||
| 77 | + | <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()"> | |||
| 78 | + | <div class="panel-head"> | |||
| 79 | + | <h2>Keyboard shortcuts</h2> | |||
| 80 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 81 | + | <kbd>esc</kbd> close</button> | |||
| 82 | + | </div> | |||
| 83 | + | <dl class="keys"> | |||
| 84 | + | <div> | |||
| 85 | + | <dt> | |||
| 86 | + | <kbd>ctrl k</kbd> | |||
| 87 | + | <kbd>:</kbd> | |||
| 88 | + | </dt> | |||
| 89 | + | <dd>go to a section, branch, tag, file or repository</dd> | |||
| 90 | + | </div> | |||
| 91 | + | <div> | |||
| 92 | + | <dt> | |||
| 93 | + | <kbd>?</kbd> | |||
| 94 | + | </dt> | |||
| 95 | + | <dd>this list</dd> | |||
| 96 | + | </div> | |||
| 97 | + | <div> | |||
| 98 | + | <dt> | |||
| 99 | + | <kbd>j</kbd> | |||
| 100 | + | <kbd>k</kbd> | |||
| 101 | + | </dt> | |||
| 102 | + | <dd>move the row cursor down / up</dd> | |||
| 103 | + | </div> | |||
| 104 | + | <div> | |||
| 105 | + | <dt> | |||
| 106 | + | <kbd>enter</kbd> | |||
| 107 | + | </dt> | |||
| 108 | + | <dd>open the row under the cursor</dd> | |||
| 109 | + | </div> | |||
| 110 | + | <div> | |||
| 111 | + | <dt> | |||
| 112 | + | <kbd>g g</kbd> | |||
| 113 | + | <kbd>G</kbd> | |||
| 114 | + | </dt> | |||
| 115 | + | <dd>first / last row</dd> | |||
| 116 | + | </div> | |||
| 117 | + | <div> | |||
| 118 | + | <dt> | |||
| 119 | + | <kbd>/</kbd> | |||
| 120 | + | </dt> | |||
| 121 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |||
| 122 | + | </div> | |||
| 123 | + | <div> | |||
| 124 | + | <dt> | |||
| 125 | + | <kbd>b</kbd> | |||
| 126 | + | </dt> | |||
| 127 | + | <dd>switch branch or tag</dd> | |||
| 128 | + | </div> | |||
| 129 | + | <div> | |||
| 130 | + | <dt> | |||
| 131 | + | <kbd>s</kbd> | |||
| 132 | + | <kbd>t</kbd> | |||
| 133 | + | <kbd>c</kbd> | |||
| 134 | + | <kbd>r</kbd> | |||
| 135 | + | <kbd>m</kbd> | |||
| 136 | + | </dt> | |||
| 137 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |||
| 138 | + | </div> | |||
| 139 | + | <div> | |||
| 140 | + | <dt> | |||
| 141 | + | <kbd>g h</kbd> | |||
| 142 | + | </dt> | |||
| 143 | + | <dd>home</dd> | |||
| 144 | + | </div> | |||
| 145 | + | <div> | |||
| 146 | + | <dt> | |||
| 147 | + | <kbd>y</kbd> | |||
| 148 | + | </dt> | |||
| 149 | + | <dd>copy a permalink pinned to the commit</dd> | |||
| 150 | + | </div> | |||
| 151 | + | <div> | |||
| 152 | + | <dt> | |||
| 153 | + | <kbd>d</kbd> | |||
| 154 | + | <kbd>u</kbd> | |||
| 155 | + | <kbd>x</kbd> | |||
| 156 | + | </dt> | |||
| 157 | + | <dd>diff: split / unified / collapse or expand all</dd> | |||
| 158 | + | </div> | |||
| 159 | + | <div> | |||
| 160 | + | <dt> | |||
| 161 | + | <kbd>n</kbd> | |||
| 162 | + | </dt> | |||
| 163 | + | <dd>merge request: next note in the diff</dd> | |||
| 164 | + | </div> | |||
| 165 | + | <div> | |||
| 166 | + | <dt> | |||
| 167 | + | <kbd>esc</kbd> | |||
| 168 | + | </dt> | |||
| 169 | + | <dd>close a dialog, leave a field</dd> | |||
| 170 | + | </div> | |||
| 171 | + | </dl> | |||
| 172 | + | </dialog> | |||
| 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()"> | |||
| 174 | + | <div class="panel-head"> | |||
| 175 | + | <label for="cmd-q" class="sr">go to</label> | |||
| 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)"> | |||
| 177 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |||
| 178 | + | <kbd>esc</kbd> | |||
| 179 | + | </button> | |||
| 180 | + | </div> | |||
| 181 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |||
| 182 | + | <li data-k="go home" data-cursor> | |||
| 183 | + | <a href="/" tabindex="-1"> | |||
| 184 | + | <span class="lbl">home</span> | |||
| 185 | + | <span class="k">go</span> | |||
| 186 | + | </a> | |||
| 187 | + | </li> | |||
| 188 | + | </ul> | |||
| 189 | + | <p class="panel-foot"> | |||
| 190 | + | <span> | |||
| 191 | + | <kbd>↑</kbd> | |||
| 192 | + | <kbd>↓</kbd> move</span> | |||
| 193 | + | <span> | |||
| 194 | + | <kbd>enter</kbd> open</span> | |||
| 195 | + | <span> | |||
| 196 | + | <kbd>esc</kbd> close</span> | |||
| 197 | + | </p> | |||
| 198 | + | </dialog> | |||
| 199 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |||
| 200 | + | k-k from:body -> ermite.move(-1); | |||
| 201 | + | k-top from:body -> ermite.edge(false); | |||
| 202 | + | k-bottom from:body -> ermite.edge(true); | |||
| 203 | + | k-open from:body -> ermite.open(); | |||
| 204 | + | k-esc from:body -> ermite.closeDialogs(); | |||
| 205 | + | k-help from:body -> ermite.dialog('panel-help'); | |||
| 206 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |||
| 207 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |||
| 208 | + | k-filter from:body -> ermite.focusFilter(); | |||
| 209 | + | k-gh from:body -> ermite.go('.brand'); | |||
| 210 | + | k-s from:body -> ermite.tab('s'); | |||
| 211 | + | k-t from:body -> ermite.tab('t'); | |||
| 212 | + | k-c from:body -> ermite.tab('c'); | |||
| 213 | + | k-r from:body -> ermite.tab('r'); | |||
| 214 | + | k-m from:body -> ermite.tab('m'); | |||
| 215 | + | k-next from:body -> ermite.nextNote(); | |||
| 216 | + | k-split from:body -> ermite.layout('split'); | |||
| 217 | + | k-unified from:body -> ermite.layout('unified'); | |||
| 218 | + | k-collapse from:body -> ermite.collapse(); | |||
| 219 | + | k-permalink from:body -> ermite.permalink()"> | |||
| 220 | + | </div> | |||
| 221 | + | </body> | |||
| 222 | + | </html> | |||
R cmd/ermite/testdata/web/refs.html → internal/app/testdata/web/refs.html +31 -75 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -39,19 +40,19 @@ | |||||
| 39 | </ol> | 40 | </ol> | ||
| 40 | </nav> | 41 | </nav> | ||
| 41 | <div class="hdr-acts"> | 42 | <div class="hdr-acts"> | ||
| 42 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 43 | + | <a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 43 | <span class="ico i-branch" aria-hidden="true"> | 44 | <span class="ico i-branch" aria-hidden="true"> | ||
| 44 | </span> | 45 | </span> | ||
| 45 | <span class="ref-name">main</span> | 46 | <span class="ref-name">main</span> | ||
| 46 | <span class="sha">SHA7</span> | 47 | <span class="sha">SHA7</span> | ||
| 47 | - | </button> | 48 | + | </a> |
| 48 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 49 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 49 | <span class="ico i-search" aria-hidden="true"> | 50 | <span class="ico i-search" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="search-label">Go to…</span> | 52 | <span class="search-label">Go to…</span> | ||
| 52 | <kbd aria-hidden="true">ctrl k</kbd> | 53 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 53 | </button> | 54 | </button> | ||
| 54 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 55 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 55 | </div> | 56 | </div> | ||
| 56 | </div> | 57 | </div> | ||
| 57 | <nav class="tabs" aria-label="Sections"> | 58 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -83,8 +84,7 @@ | |||||
| 83 | <main id="main" tabindex="-1"> | 84 | <main id="main" tabindex="-1"> | ||
| 84 | <div class="filter"> | 85 | <div class="filter"> | ||
| 85 | <label for="ref-filter" class="sr">filter</label> | 86 | <label for="ref-filter" class="sr">filter</label> | ||
| 86 | - | <input id="ref-filter" type="search" data-filter placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | 87 | + | <input id="ref-filter" type="search" data-filter="#ref-rows > tr" placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="ermite.filter(this)"> |
| 87 | - | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"> | |||
| 88 | </div> | 88 | </div> | ||
| 89 | <table class="list refs" aria-label="Branches and tags"> | 89 | <table class="list refs" aria-label="Branches and tags"> | ||
| 90 | <thead> | 90 | <thead> | ||
| @@ -194,6 +194,8 @@ | |||||
| 194 | </span> | 194 | </span> | ||
| 195 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | 195 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | ||
| 196 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 196 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 197 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |||
| 198 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |||
| 197 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 199 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | ||
| 198 | gap -> ermiteLive.reload()"> | 200 | gap -> ermiteLive.reload()"> | ||
| 199 | </span> | 201 | </span> | ||
| @@ -297,7 +299,7 @@ | |||||
| 297 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 299 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 298 | <div class="panel-head"> | 300 | <div class="panel-head"> | ||
| 299 | <label for="refs-filter" class="sr">filter branches and tags</label> | 301 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 300 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 302 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 301 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 303 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 302 | <kbd>esc</kbd> | 304 | <kbd>esc</kbd> | ||
| 303 | </button> | 305 | </button> | ||
| @@ -336,13 +338,7 @@ | |||||
| 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()"> | 338 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 337 | <div class="panel-head"> | 339 | <div class="panel-head"> | ||
| 338 | <label for="cmd-q" class="sr">go to</label> | 340 | <label for="cmd-q" class="sr">go to</label> | ||
| 339 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 341 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 342 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 343 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 344 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 345 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 346 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 342 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 347 | <kbd>esc</kbd> | 343 | <kbd>esc</kbd> | ||
| 348 | </button> | 344 | </button> | ||
| @@ -425,67 +421,27 @@ | |||||
| 425 | <kbd>esc</kbd> close</span> | 421 | <kbd>esc</kbd> close</span> | ||
| 426 | </p> | 422 | </p> | ||
| 427 | </dialog> | 423 | </dialog> | ||
| 428 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 424 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 429 | - | if (q('dialog[open]').count) return; | 425 | + | k-k from:body -> ermite.move(-1); |
| 430 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 426 | + | k-top from:body -> ermite.edge(false); |
| 431 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 427 | + | k-bottom from:body -> ermite.edge(true); |
| 432 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 428 | + | k-open from:body -> ermite.open(); |
| 433 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 429 | + | k-esc from:body -> ermite.closeDialogs(); |
| 434 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 430 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 435 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 431 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 436 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 432 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 437 | - | })(1); | 433 | + | k-filter from:body -> ermite.focusFilter(); |
| 438 | - | k-k from:body -> (async (d) => { | 434 | + | k-gh from:body -> ermite.go('.brand'); |
| 439 | - | if (q('dialog[open]').count) return; | 435 | + | k-s from:body -> ermite.tab('s'); |
| 440 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 436 | + | k-t from:body -> ermite.tab('t'); |
| 441 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 437 | + | k-c from:body -> ermite.tab('c'); |
| 442 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 438 | + | k-r from:body -> ermite.tab('r'); |
| 443 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 439 | + | k-m from:body -> ermite.tab('m'); |
| 444 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 440 | + | k-next from:body -> ermite.nextNote(); |
| 445 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 441 | + | k-split from:body -> ermite.layout('split'); |
| 446 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 442 | + | k-unified from:body -> ermite.layout('unified'); |
| 447 | - | })(-1); | 443 | + | k-collapse from:body -> ermite.collapse(); |
| 448 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 444 | + | k-permalink from:body -> ermite.permalink()"> |
| 449 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 450 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 451 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 452 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 453 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 454 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 455 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 456 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 457 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 458 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 459 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 460 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 461 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 462 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 463 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 464 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 465 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 466 | - | k-next from:body -> { | |||
| 467 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 468 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 469 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 470 | - | }; | |||
| 471 | - | k-split from:body -> { | |||
| 472 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 473 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 474 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 475 | - | }; | |||
| 476 | - | k-unified from:body -> { | |||
| 477 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 478 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 479 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 480 | - | }; | |||
| 481 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 482 | - | k-permalink from:body -> { | |||
| 483 | - | let b = document.body.dataset; | |||
| 484 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 485 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 486 | - | navigator.clipboard?.writeText(location.href); | |||
| 487 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 488 | - | }"> | |||
| 489 | </div> | 445 | </div> | ||
| 490 | </body> | 446 | </body> | ||
| 491 | </html> | 447 | </html> | ||
R cmd/ermite/testdata/web/summary.html → internal/app/testdata/web/summary.html +32 -75 renamed, 62% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -36,19 +37,19 @@ | |||||
| 36 | </ol> | 37 | </ol> | ||
| 37 | </nav> | 38 | </nav> | ||
| 38 | <div class="hdr-acts"> | 39 | <div class="hdr-acts"> | ||
| 39 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 40 | + | <a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 40 | <span class="ico i-branch" aria-hidden="true"> | 41 | <span class="ico i-branch" aria-hidden="true"> | ||
| 41 | </span> | 42 | </span> | ||
| 42 | <span class="ref-name">main</span> | 43 | <span class="ref-name">main</span> | ||
| 43 | <span class="sha">SHA7</span> | 44 | <span class="sha">SHA7</span> | ||
| 44 | - | </button> | 45 | + | </a> |
| 45 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 46 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 46 | <span class="ico i-search" aria-hidden="true"> | 47 | <span class="ico i-search" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="search-label">Go to…</span> | 49 | <span class="search-label">Go to…</span> | ||
| 49 | <kbd aria-hidden="true">ctrl k</kbd> | 50 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 50 | </button> | 51 | </button> | ||
| 51 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 52 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 52 | </div> | 53 | </div> | ||
| 53 | </div> | 54 | </div> | ||
| 54 | <nav class="tabs" aria-label="Sections"> | 55 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -85,7 +86,7 @@ | |||||
| 85 | <h2 class="card-h" id="readme-h"> | 86 | <h2 class="card-h" id="readme-h"> | ||
| 86 | <span class="ico i-file" aria-hidden="true"> | 87 | <span class="ico i-file" aria-hidden="true"> | ||
| 87 | </span> README.md</h2> | 88 | </span> README.md</h2> | ||
| 88 | - | <article class="prose" aria-label="README.md"> | 89 | + | <article class="prose" aria-label="README.md" hx-ignore> |
| 89 | <h1 id="md-demo">Demo</h1> | 90 | <h1 id="md-demo">Demo</h1> | ||
| 90 | <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p> | 91 | <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p> | ||
| 91 | <h2 id="md-install">Install</h2> | 92 | <h2 id="md-install">Install</h2> | ||
| @@ -118,7 +119,7 @@ | |||||
| 118 | <div class="clone-line"> | 119 | <div class="clone-line"> | ||
| 119 | <span class="mute" aria-hidden="true">$ </span> | 120 | <span class="mute" aria-hidden="true">$ </span> | ||
| 120 | <code>git clone http://example.test/demo.git</code> | 121 | <code>git clone http://example.test/demo.git</code> | ||
| 121 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | 122 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="ermite.copy(this)">Copy</button> |
| 122 | </div> | 123 | </div> | ||
| 123 | </div> | 124 | </div> | ||
| 124 | </section> | 125 | </section> | ||
| @@ -212,6 +213,8 @@ | |||||
| 212 | </span> | 213 | </span> | ||
| 213 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | 214 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | ||
| 214 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 215 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 216 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |||
| 217 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |||
| 215 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 218 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | ||
| 216 | gap -> ermiteLive.reload()"> | 219 | gap -> ermiteLive.reload()"> | ||
| 217 | </span> | 220 | </span> | ||
| @@ -315,7 +318,7 @@ | |||||
| 315 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 318 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 316 | <div class="panel-head"> | 319 | <div class="panel-head"> | ||
| 317 | <label for="refs-filter" class="sr">filter branches and tags</label> | 320 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 318 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 321 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 319 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 322 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 320 | <kbd>esc</kbd> | 323 | <kbd>esc</kbd> | ||
| 321 | </button> | 324 | </button> | ||
| @@ -354,13 +357,7 @@ | |||||
| 354 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 355 | <div class="panel-head"> | 358 | <div class="panel-head"> | ||
| 356 | <label for="cmd-q" class="sr">go to</label> | 359 | <label for="cmd-q" class="sr">go to</label> | ||
| 357 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 358 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 359 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 360 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 361 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 362 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 363 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 364 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 361 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 365 | <kbd>esc</kbd> | 362 | <kbd>esc</kbd> | ||
| 366 | </button> | 363 | </button> | ||
| @@ -443,67 +440,27 @@ | |||||
| 443 | <kbd>esc</kbd> close</span> | 440 | <kbd>esc</kbd> close</span> | ||
| 444 | </p> | 441 | </p> | ||
| 445 | </dialog> | 442 | </dialog> | ||
| 446 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 443 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 447 | - | if (q('dialog[open]').count) return; | 444 | + | k-k from:body -> ermite.move(-1); |
| 448 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 445 | + | k-top from:body -> ermite.edge(false); |
| 449 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 446 | + | k-bottom from:body -> ermite.edge(true); |
| 450 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 447 | + | k-open from:body -> ermite.open(); |
| 451 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 448 | + | k-esc from:body -> ermite.closeDialogs(); |
| 452 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 449 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 453 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 450 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 454 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 451 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 455 | - | })(1); | 452 | + | k-filter from:body -> ermite.focusFilter(); |
| 456 | - | k-k from:body -> (async (d) => { | 453 | + | k-gh from:body -> ermite.go('.brand'); |
| 457 | - | if (q('dialog[open]').count) return; | 454 | + | k-s from:body -> ermite.tab('s'); |
| 458 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 455 | + | k-t from:body -> ermite.tab('t'); |
| 459 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 456 | + | k-c from:body -> ermite.tab('c'); |
| 460 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 457 | + | k-r from:body -> ermite.tab('r'); |
| 461 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 458 | + | k-m from:body -> ermite.tab('m'); |
| 462 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 459 | + | k-next from:body -> ermite.nextNote(); |
| 463 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 460 | + | k-split from:body -> ermite.layout('split'); |
| 464 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 461 | + | k-unified from:body -> ermite.layout('unified'); |
| 465 | - | })(-1); | 462 | + | k-collapse from:body -> ermite.collapse(); |
| 466 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 463 | + | k-permalink from:body -> ermite.permalink()"> |
| 467 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 468 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 469 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 470 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 471 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 472 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 473 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 474 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 475 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 476 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 477 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 478 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 479 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 480 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 481 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 482 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 483 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 484 | - | k-next from:body -> { | |||
| 485 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 486 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 487 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 488 | - | }; | |||
| 489 | - | k-split from:body -> { | |||
| 490 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 491 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 492 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 493 | - | }; | |||
| 494 | - | k-unified from:body -> { | |||
| 495 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 496 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 497 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 498 | - | }; | |||
| 499 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 500 | - | k-permalink from:body -> { | |||
| 501 | - | let b = document.body.dataset; | |||
| 502 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 503 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 504 | - | navigator.clipboard?.writeText(location.href); | |||
| 505 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 506 | - | }"> | |||
| 507 | </div> | 464 | </div> | ||
| 508 | </body> | 465 | </body> | ||
| 509 | </html> | 466 | </html> | ||
R cmd/ermite/testdata/web/tree-nested.html → internal/app/testdata/web/tree-nested.html +28 -73 renamed, 58% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -48,19 +49,19 @@ | |||||
| 48 | </ol> | 49 | </ol> | ||
| 49 | </nav> | 50 | </nav> | ||
| 50 | <div class="hdr-acts"> | 51 | <div class="hdr-acts"> | ||
| 51 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 52 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 52 | <span class="ico i-branch" aria-hidden="true"> | 53 | <span class="ico i-branch" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="ref-name">main</span> | 55 | <span class="ref-name">main</span> | ||
| 55 | <span class="sha">SHA7</span> | 56 | <span class="sha">SHA7</span> | ||
| 56 | - | </button> | 57 | + | </a> |
| 57 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 58 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 58 | <span class="ico i-search" aria-hidden="true"> | 59 | <span class="ico i-search" aria-hidden="true"> | ||
| 59 | </span> | 60 | </span> | ||
| 60 | <span class="search-label">Go to…</span> | 61 | <span class="search-label">Go to…</span> | ||
| 61 | <kbd aria-hidden="true">ctrl k</kbd> | 62 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 62 | </button> | 63 | </button> | ||
| 63 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 64 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 64 | </div> | 65 | </div> | ||
| 65 | </div> | 66 | </div> | ||
| 66 | <nav class="tabs" aria-label="Sections"> | 67 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -250,7 +251,7 @@ | |||||
| 250 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 251 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 251 | <div class="panel-head"> | 252 | <div class="panel-head"> | ||
| 252 | <label for="refs-filter" class="sr">filter branches and tags</label> | 253 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 253 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 254 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 254 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 255 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 255 | <kbd>esc</kbd> | 256 | <kbd>esc</kbd> | ||
| 256 | </button> | 257 | </button> | ||
| @@ -285,13 +286,7 @@ | |||||
| 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()"> | 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()"> | ||
| 286 | <div class="panel-head"> | 287 | <div class="panel-head"> | ||
| 287 | <label for="cmd-q" class="sr">go to</label> | 288 | <label for="cmd-q" class="sr">go to</label> | ||
| 288 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 289 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 290 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 291 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 292 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 293 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 294 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 295 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 290 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 296 | <kbd>esc</kbd> | 291 | <kbd>esc</kbd> | ||
| 297 | </button> | 292 | </button> | ||
| @@ -362,67 +357,27 @@ | |||||
| 362 | <kbd>esc</kbd> close</span> | 357 | <kbd>esc</kbd> close</span> | ||
| 363 | </p> | 358 | </p> | ||
| 364 | </dialog> | 359 | </dialog> | ||
| 365 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 360 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 366 | - | if (q('dialog[open]').count) return; | 361 | + | k-k from:body -> ermite.move(-1); |
| 367 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 362 | + | k-top from:body -> ermite.edge(false); |
| 368 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 363 | + | k-bottom from:body -> ermite.edge(true); |
| 369 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 364 | + | k-open from:body -> ermite.open(); |
| 370 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 365 | + | k-esc from:body -> ermite.closeDialogs(); |
| 371 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 366 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 372 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 367 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 373 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 368 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 374 | - | })(1); | 369 | + | k-filter from:body -> ermite.focusFilter(); |
| 375 | - | k-k from:body -> (async (d) => { | 370 | + | k-gh from:body -> ermite.go('.brand'); |
| 376 | - | if (q('dialog[open]').count) return; | 371 | + | k-s from:body -> ermite.tab('s'); |
| 377 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 372 | + | k-t from:body -> ermite.tab('t'); |
| 378 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 373 | + | k-c from:body -> ermite.tab('c'); |
| 379 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 374 | + | k-r from:body -> ermite.tab('r'); |
| 380 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 375 | + | k-m from:body -> ermite.tab('m'); |
| 381 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 376 | + | k-next from:body -> ermite.nextNote(); |
| 382 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 377 | + | k-split from:body -> ermite.layout('split'); |
| 383 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 378 | + | k-unified from:body -> ermite.layout('unified'); |
| 384 | - | })(-1); | 379 | + | k-collapse from:body -> ermite.collapse(); |
| 385 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 380 | + | k-permalink from:body -> ermite.permalink()"> |
| 386 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 387 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 388 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 389 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 390 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 391 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 392 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 393 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 394 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 395 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 396 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 397 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 398 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 399 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 400 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 401 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 402 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 403 | - | k-next from:body -> { | |||
| 404 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 405 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 406 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 407 | - | }; | |||
| 408 | - | k-split from:body -> { | |||
| 409 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 410 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 411 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 412 | - | }; | |||
| 413 | - | k-unified from:body -> { | |||
| 414 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 415 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 416 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 417 | - | }; | |||
| 418 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 419 | - | k-permalink from:body -> { | |||
| 420 | - | let b = document.body.dataset; | |||
| 421 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 422 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 423 | - | navigator.clipboard?.writeText(location.href); | |||
| 424 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 425 | - | }"> | |||
| 426 | </div> | 381 | </div> | ||
| 427 | </body> | 382 | </body> | ||
| 428 | </html> | 383 | </html> | ||
R cmd/ermite/testdata/web/tree-root.html → internal/app/testdata/web/tree-root.html +29 -74 renamed, 69% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -42,19 +43,19 @@ | |||||
| 42 | </ol> | 43 | </ol> | ||
| 43 | </nav> | 44 | </nav> | ||
| 44 | <div class="hdr-acts"> | 45 | <div class="hdr-acts"> | ||
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 46 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 46 | <span class="ico i-branch" aria-hidden="true"> | 47 | <span class="ico i-branch" aria-hidden="true"> | ||
| 47 | </span> | 48 | </span> | ||
| 48 | <span class="ref-name">main</span> | 49 | <span class="ref-name">main</span> | ||
| 49 | <span class="sha">SHA7</span> | 50 | <span class="sha">SHA7</span> | ||
| 50 | - | </button> | 51 | + | </a> |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 52 | <span class="ico i-search" aria-hidden="true"> | 53 | <span class="ico i-search" aria-hidden="true"> | ||
| 53 | </span> | 54 | </span> | ||
| 54 | <span class="search-label">Go to…</span> | 55 | <span class="search-label">Go to…</span> | ||
| 55 | <kbd aria-hidden="true">ctrl k</kbd> | 56 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 56 | </button> | 57 | </button> | ||
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 58 | </div> | 59 | </div> | ||
| 59 | </div> | 60 | </div> | ||
| 60 | <nav class="tabs" aria-label="Sections"> | 61 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -356,7 +357,7 @@ | |||||
| 356 | <h2 class="card-h" id="readme-h"> | 357 | <h2 class="card-h" id="readme-h"> | ||
| 357 | <span class="ico i-file" aria-hidden="true"> | 358 | <span class="ico i-file" aria-hidden="true"> | ||
| 358 | </span> README.md</h2> | 359 | </span> README.md</h2> | ||
| 359 | - | <article class="prose" aria-label="README.md"> | 360 | + | <article class="prose" aria-label="README.md" hx-ignore> |
| 360 | <h1 id="md-files">Files</h1> | 361 | <h1 id="md-files">Files</h1> | ||
| 361 | <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p> | 362 | <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p> | ||
| 362 | </article> | 363 | </article> | ||
| @@ -477,7 +478,7 @@ | |||||
| 477 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 478 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 478 | <div class="panel-head"> | 479 | <div class="panel-head"> | ||
| 479 | <label for="refs-filter" class="sr">filter branches and tags</label> | 480 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 480 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 481 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 481 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 482 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 482 | <kbd>esc</kbd> | 483 | <kbd>esc</kbd> | ||
| 483 | </button> | 484 | </button> | ||
| @@ -512,13 +513,7 @@ | |||||
| 512 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 513 | <div class="panel-head"> | 514 | <div class="panel-head"> | ||
| 514 | <label for="cmd-q" class="sr">go to</label> | 515 | <label for="cmd-q" class="sr">go to</label> | ||
| 515 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 516 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 517 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 518 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 519 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 520 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 521 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 522 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 517 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 523 | <kbd>esc</kbd> | 518 | <kbd>esc</kbd> | ||
| 524 | </button> | 519 | </button> | ||
| @@ -589,67 +584,27 @@ | |||||
| 589 | <kbd>esc</kbd> close</span> | 584 | <kbd>esc</kbd> close</span> | ||
| 590 | </p> | 585 | </p> | ||
| 591 | </dialog> | 586 | </dialog> | ||
| 592 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 587 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 593 | - | if (q('dialog[open]').count) return; | 588 | + | k-k from:body -> ermite.move(-1); |
| 594 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 589 | + | k-top from:body -> ermite.edge(false); |
| 595 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 590 | + | k-bottom from:body -> ermite.edge(true); |
| 596 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 591 | + | k-open from:body -> ermite.open(); |
| 597 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 592 | + | k-esc from:body -> ermite.closeDialogs(); |
| 598 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 593 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 599 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 594 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 600 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 595 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 601 | - | })(1); | 596 | + | k-filter from:body -> ermite.focusFilter(); |
| 602 | - | k-k from:body -> (async (d) => { | 597 | + | k-gh from:body -> ermite.go('.brand'); |
| 603 | - | if (q('dialog[open]').count) return; | 598 | + | k-s from:body -> ermite.tab('s'); |
| 604 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 599 | + | k-t from:body -> ermite.tab('t'); |
| 605 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 600 | + | k-c from:body -> ermite.tab('c'); |
| 606 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 601 | + | k-r from:body -> ermite.tab('r'); |
| 607 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 602 | + | k-m from:body -> ermite.tab('m'); |
| 608 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 603 | + | k-next from:body -> ermite.nextNote(); |
| 609 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 604 | + | k-split from:body -> ermite.layout('split'); |
| 610 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 605 | + | k-unified from:body -> ermite.layout('unified'); |
| 611 | - | })(-1); | 606 | + | k-collapse from:body -> ermite.collapse(); |
| 612 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 607 | + | k-permalink from:body -> ermite.permalink()"> |
| 613 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 614 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 615 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 616 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 617 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 618 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 619 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 620 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 621 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 622 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 623 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 624 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 625 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 626 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 627 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 628 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 629 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 630 | - | k-next from:body -> { | |||
| 631 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 632 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 633 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 634 | - | }; | |||
| 635 | - | k-split from:body -> { | |||
| 636 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 637 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 638 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 639 | - | }; | |||
| 640 | - | k-unified from:body -> { | |||
| 641 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 642 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 643 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 644 | - | }; | |||
| 645 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 646 | - | k-permalink from:body -> { | |||
| 647 | - | let b = document.body.dataset; | |||
| 648 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 649 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 650 | - | navigator.clipboard?.writeText(location.href); | |||
| 651 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 652 | - | }"> | |||
| 653 | </div> | 608 | </div> | ||
| 654 | </body> | 609 | </body> | ||
| 655 | </html> | 610 | </html> | ||
R cmd/ermite/testdata/web/tree-slash-ref.html → internal/app/testdata/web/tree-slash-ref.html +28 -73 renamed, 58% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">feature/x</span> | 52 | <span class="ref-name">feature/x</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -247,7 +248,7 @@ | |||||
| 247 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 248 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 248 | <div class="panel-head"> | 249 | <div class="panel-head"> | ||
| 249 | <label for="refs-filter" class="sr">filter branches and tags</label> | 250 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 250 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 251 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 251 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 252 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 252 | <kbd>esc</kbd> | 253 | <kbd>esc</kbd> | ||
| 253 | </button> | 254 | </button> | ||
| @@ -282,13 +283,7 @@ | |||||
| 282 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 283 | <div class="panel-head"> | 284 | <div class="panel-head"> | ||
| 284 | <label for="cmd-q" class="sr">go to</label> | 285 | <label for="cmd-q" class="sr">go to</label> | ||
| 285 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 286 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 287 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 288 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 289 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 290 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 291 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 292 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 287 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 293 | <kbd>esc</kbd> | 288 | <kbd>esc</kbd> | ||
| 294 | </button> | 289 | </button> | ||
| @@ -359,67 +354,27 @@ | |||||
| 359 | <kbd>esc</kbd> close</span> | 354 | <kbd>esc</kbd> close</span> | ||
| 360 | </p> | 355 | </p> | ||
| 361 | </dialog> | 356 | </dialog> | ||
| 362 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 357 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 363 | - | if (q('dialog[open]').count) return; | 358 | + | k-k from:body -> ermite.move(-1); |
| 364 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 359 | + | k-top from:body -> ermite.edge(false); |
| 365 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 360 | + | k-bottom from:body -> ermite.edge(true); |
| 366 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 361 | + | k-open from:body -> ermite.open(); |
| 367 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 362 | + | k-esc from:body -> ermite.closeDialogs(); |
| 368 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 363 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 369 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 364 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 370 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 365 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 371 | - | })(1); | 366 | + | k-filter from:body -> ermite.focusFilter(); |
| 372 | - | k-k from:body -> (async (d) => { | 367 | + | k-gh from:body -> ermite.go('.brand'); |
| 373 | - | if (q('dialog[open]').count) return; | 368 | + | k-s from:body -> ermite.tab('s'); |
| 374 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 369 | + | k-t from:body -> ermite.tab('t'); |
| 375 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 370 | + | k-c from:body -> ermite.tab('c'); |
| 376 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 371 | + | k-r from:body -> ermite.tab('r'); |
| 377 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 372 | + | k-m from:body -> ermite.tab('m'); |
| 378 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 373 | + | k-next from:body -> ermite.nextNote(); |
| 379 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 374 | + | k-split from:body -> ermite.layout('split'); |
| 380 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 375 | + | k-unified from:body -> ermite.layout('unified'); |
| 381 | - | })(-1); | 376 | + | k-collapse from:body -> ermite.collapse(); |
| 382 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 377 | + | k-permalink from:body -> ermite.permalink()"> |
| 383 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 384 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 385 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 386 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 387 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 388 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 389 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 390 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 391 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 392 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 393 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 394 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 395 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 396 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 397 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 398 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 399 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 400 | - | k-next from:body -> { | |||
| 401 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 402 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 403 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 404 | - | }; | |||
| 405 | - | k-split from:body -> { | |||
| 406 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 407 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 408 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 409 | - | }; | |||
| 410 | - | k-unified from:body -> { | |||
| 411 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 412 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 413 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 414 | - | }; | |||
| 415 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 416 | - | k-permalink from:body -> { | |||
| 417 | - | let b = document.body.dataset; | |||
| 418 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 419 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 420 | - | navigator.clipboard?.writeText(location.href); | |||
| 421 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 422 | - | }"> | |||
| 423 | </div> | 378 | </div> | ||
| 424 | </body> | 379 | </body> | ||
| 425 | </html> | 380 | </html> | ||
R cmd/ermite/testdata/web/tree-subdir.html → internal/app/testdata/web/tree-subdir.html +29 -74 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||||
| 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | ||
| 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | ||
| 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | ||
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |||
| 11 | <script src="/-/static/js/htmx.min.HASH.js" defer> | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | ||
| 12 | </script> | 13 | </script> | ||
| 13 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | ||
| @@ -45,19 +46,19 @@ | |||||
| 45 | </ol> | 46 | </ol> | ||
| 46 | </nav> | 47 | </nav> | ||
| 47 | <div class="hdr-acts"> | 48 | <div class="hdr-acts"> | ||
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> |
| 49 | <span class="ico i-branch" aria-hidden="true"> | 50 | <span class="ico i-branch" aria-hidden="true"> | ||
| 50 | </span> | 51 | </span> | ||
| 51 | <span class="ref-name">main</span> | 52 | <span class="ref-name">main</span> | ||
| 52 | <span class="sha">SHA7</span> | 53 | <span class="sha">SHA7</span> | ||
| 53 | - | </button> | 54 | + | </a> |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> |
| 55 | <span class="ico i-search" aria-hidden="true"> | 56 | <span class="ico i-search" aria-hidden="true"> | ||
| 56 | </span> | 57 | </span> | ||
| 57 | <span class="search-label">Go to…</span> | 58 | <span class="search-label">Go to…</span> | ||
| 58 | <kbd aria-hidden="true">ctrl k</kbd> | 59 | <kbd aria-hidden="true">ctrl k</kbd> | ||
| 59 | </button> | 60 | </button> | ||
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> |
| 61 | </div> | 62 | </div> | ||
| 62 | </div> | 63 | </div> | ||
| 63 | <nav class="tabs" aria-label="Sections"> | 64 | <nav class="tabs" aria-label="Sections"> | ||
| @@ -151,7 +152,7 @@ | |||||
| 151 | <h2 class="card-h" id="readme-h"> | 152 | <h2 class="card-h" id="readme-h"> | ||
| 152 | <span class="ico i-file" aria-hidden="true"> | 153 | <span class="ico i-file" aria-hidden="true"> | ||
| 153 | </span> README.md</h2> | 154 | </span> README.md</h2> | ||
| 154 | - | <article class="prose" aria-label="README.md"> | 155 | + | <article class="prose" aria-label="README.md" hx-ignore> |
| 155 | <h1 id="md-docs">Docs</h1> | 156 | <h1 id="md-docs">Docs</h1> | ||
| 156 | <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p> | 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> | ||
| 157 | </article> | 158 | </article> | ||
| @@ -272,7 +273,7 @@ | |||||
| 272 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 273 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 273 | <div class="panel-head"> | 274 | <div class="panel-head"> | ||
| 274 | <label for="refs-filter" class="sr">filter branches and tags</label> | 275 | <label for="refs-filter" class="sr">filter branches and tags</label> | ||
| 275 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | 276 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> |
| 276 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 277 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 277 | <kbd>esc</kbd> | 278 | <kbd>esc</kbd> | ||
| 278 | </button> | 279 | </button> | ||
| @@ -307,13 +308,7 @@ | |||||
| 307 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 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()"> | ||
| 308 | <div class="panel-head"> | 309 | <div class="panel-head"> | ||
| 309 | <label for="cmd-q" class="sr">go to</label> | 310 | <label for="cmd-q" class="sr">go to</label> | ||
| 310 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | 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)"> |
| 311 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 312 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 313 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 314 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 315 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 316 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 317 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | 312 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | ||
| 318 | <kbd>esc</kbd> | 313 | <kbd>esc</kbd> | ||
| 319 | </button> | 314 | </button> | ||
| @@ -384,67 +379,27 @@ | |||||
| 384 | <kbd>esc</kbd> close</span> | 379 | <kbd>esc</kbd> close</span> | ||
| 385 | </p> | 380 | </p> | ||
| 386 | </dialog> | 381 | </dialog> | ||
| 387 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | 382 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); |
| 388 | - | if (q('dialog[open]').count) return; | 383 | + | k-k from:body -> ermite.move(-1); |
| 389 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 384 | + | k-top from:body -> ermite.edge(false); |
| 390 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 385 | + | k-bottom from:body -> ermite.edge(true); |
| 391 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 386 | + | k-open from:body -> ermite.open(); |
| 392 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 387 | + | k-esc from:body -> ermite.closeDialogs(); |
| 393 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 388 | + | k-help from:body -> ermite.dialog('panel-help'); |
| 394 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 389 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); |
| 395 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 390 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); |
| 396 | - | })(1); | 391 | + | k-filter from:body -> ermite.focusFilter(); |
| 397 | - | k-k from:body -> (async (d) => { | 392 | + | k-gh from:body -> ermite.go('.brand'); |
| 398 | - | if (q('dialog[open]').count) return; | 393 | + | k-s from:body -> ermite.tab('s'); |
| 399 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | 394 | + | k-t from:body -> ermite.tab('t'); |
| 400 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | 395 | + | k-c from:body -> ermite.tab('c'); |
| 401 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | 396 | + | k-r from:body -> ermite.tab('r'); |
| 402 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | 397 | + | k-m from:body -> ermite.tab('m'); |
| 403 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | 398 | + | k-next from:body -> ermite.nextNote(); |
| 404 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | 399 | + | k-split from:body -> ermite.layout('split'); |
| 405 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | 400 | + | k-unified from:body -> ermite.layout('unified'); |
| 406 | - | })(-1); | 401 | + | k-collapse from:body -> ermite.collapse(); |
| 407 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | 402 | + | k-permalink from:body -> ermite.permalink()"> |
| 408 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 409 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 410 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 411 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |||
| 412 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |||
| 413 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |||
| 414 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 415 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |||
| 416 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 417 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 418 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 419 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |||
| 420 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |||
| 421 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |||
| 422 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |||
| 423 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 424 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |||
| 425 | - | k-next from:body -> { | |||
| 426 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |||
| 427 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |||
| 428 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |||
| 429 | - | }; | |||
| 430 | - | k-split from:body -> { | |||
| 431 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 432 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 433 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 434 | - | }; | |||
| 435 | - | k-unified from:body -> { | |||
| 436 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |||
| 437 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 438 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |||
| 439 | - | }; | |||
| 440 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |||
| 441 | - | k-permalink from:body -> { | |||
| 442 | - | let b = document.body.dataset; | |||
| 443 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 444 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 445 | - | navigator.clipboard?.writeText(location.href); | |||
| 446 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 447 | - | }"> | |||
| 448 | </div> | 403 | </div> | ||
| 449 | </body> | 404 | </body> | ||
| 450 | </html> | 405 | </html> | ||
R cmd/ermite/web.go → internal/app/web.go +45 -44 renamed, 56% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -13,40 +13,35 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 15 | 15 | ||||
| 16 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 17 | "bnema.dev/ermite/internal/adapters/httpserver" | 16 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 18 | - | "bnema.dev/ermite/internal/adapters/rpc" | |||
| 19 | "bnema.dev/ermite/internal/adapters/web" | 17 | "bnema.dev/ermite/internal/adapters/web" | ||
| 20 | - | "bnema.dev/ermite/internal/app" | |||
| 21 | "bnema.dev/ermite/internal/clientlimit" | 18 | "bnema.dev/ermite/internal/clientlimit" | ||
| 22 | "bnema.dev/ermite/internal/config" | 19 | "bnema.dev/ermite/internal/config" | ||
| 23 | "bnema.dev/ermite/internal/domain" | 20 | "bnema.dev/ermite/internal/domain" | ||
| 21 | + | "bnema.dev/ermite/internal/ports" | |||
| 22 | + | "bnema.dev/ermite/internal/usecase" | |||
| 24 | ) | 23 | ) | ||
| 25 | 24 | ||||
| 26 | - | // newWeb builds the web pages on the API of this process: the generated | 25 | + | // newWeb builds the web pages on the use cases of this process, through the |
| 27 | - | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | 26 | + | // ports (ADR 0020), like the API and Git over HTTP. |
| 28 | // | 27 | // | ||
| 29 | // The owner's session cookie turns a request into the owner's (web.Server | 28 | // The owner's session cookie turns a request into the owner's (web.Server | ||
| 30 | - | // checks it with sessions); every other request is anonymous. | 29 | + | // checks it with sessions); every other request is anonymous. The web passes |
| 31 | - | func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) { | 30 | + | // the principal of the request to each use case call itself. |
| 32 | - | hc := api.InProcessClient() | 31 | + | func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) { |
| 33 | - | srv, err := web.New(web.Config{ | 32 | + | return web.New(web.Config{ |
| 34 | - | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | 33 | + | Repos: f.Repos, |
| 35 | - | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | 34 | + | Browse: f.Browse, |
| 36 | - | MergeRequests: ermiteconnect.NewMergeRequestServiceClient(hc, rpc.InProcessURL), | 35 | + | MergeRequests: f.MergeRequests, |
| 37 | - | Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL), | 36 | + | Events: f.EventLog, |
| 38 | PublicURL: cfg.PublicURL, | 37 | PublicURL: cfg.PublicURL, | ||
| 39 | SourceURL: cfg.SourceURL, | 38 | SourceURL: cfg.SourceURL, | ||
| 40 | Version: serverVersion(), | 39 | Version: serverVersion(), | ||
| 41 | 40 | ||||
| 42 | - | Sessions: webSessions{sessions}, | 41 | + | Sessions: webSessions{f.Sessions}, |
| 43 | Limiter: limiter, | 42 | Limiter: limiter, | ||
| 44 | TrustedProxies: cfg.TrustedProxies, | 43 | TrustedProxies: cfg.TrustedProxies, | ||
| 45 | }) | 44 | }) | ||
| 46 | - | if err != nil { | |||
| 47 | - | return nil, err | |||
| 48 | - | } | |||
| 49 | - | return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil | |||
| 50 | } | 45 | } | ||
| 51 | 46 | ||||
| 52 | // sessionsNeedHTTPS reports whether browser sign-in cannot work on this public | 47 | // sessionsNeedHTTPS reports whether browser sign-in cannot work on this public | ||
| @@ -79,15 +74,34 @@ func warnSessionsNeedHTTPS(ctx context.Context, publicURL string) { | |||||
| 79 | "and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https") | 74 | "and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https") | ||
| 80 | } | 75 | } | ||
| 81 | 76 | ||||
| 82 | - | // webSessions adapts the session use cases to web.SessionAuth: a valid token | 77 | + | // warnSharedClientKey tells the operator, at startup, that without trusted |
| 83 | - | // gives the context of the owner, whose principal the API calls of the page | 78 | + | // proxies every request is attributed to the address of its TCP peer. Behind a |
| 84 | - | // carry (rpc.API.InProcessClient). | 79 | + | // reverse proxy that is the proxy: all the clients then share one key, and so |
| 85 | - | type webSessions struct{ *app.Sessions } | 80 | + | // one cap on the Git backends running at once (maxPerClient) and one budget of |
| 81 | + | // failed authentications. The keying is unchanged; the fix is to list the | |||
| 82 | + | // proxy networks. | |||
| 83 | + | func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClient int) { | |||
| 84 | + | if len(trusted) > 0 || maxPerClient <= 0 { | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | log := zerowrap.FromCtx(ctx) | |||
| 88 | + | log.Warn().Int("max_git_backends_per_client", maxPerClient).Msg( | |||
| 89 | + | config.EnvTrustedProxies + " is not set: clients are told apart by their TCP peer address only. " + | |||
| 90 | + | "Behind a reverse proxy, all clients share the proxy's address, so one cap of concurrent Git " + | |||
| 91 | + | "requests and one budget of failed authentications; set it to the proxy networks") | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // webSessions adapts the session use cases to ports.WebSessionService: a valid | |||
| 95 | + | // token gives the owner, the principal the web passes to the use cases for | |||
| 96 | + | // that request. | |||
| 97 | + | type webSessions struct{ *usecase.Sessions } | |||
| 98 | + | ||||
| 99 | + | var _ ports.WebSessionService = webSessions{} | |||
| 86 | 100 | ||||
| 87 | func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) { | 101 | func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) { | ||
| 88 | token, expiresAt, err := s.Sessions.Login(ctx, nonce) | 102 | token, expiresAt, err := s.Sessions.Login(ctx, nonce) | ||
| 89 | if errors.Is(err, domain.ErrUnauthenticated) { | 103 | if errors.Is(err, domain.ErrUnauthenticated) { | ||
| 90 | - | return "", 0, web.ErrLinkRejected | 104 | + | return "", 0, ports.ErrLinkRejected |
| 91 | } | 105 | } | ||
| 92 | if err != nil { | 106 | if err != nil { | ||
| 93 | return "", 0, err | 107 | return "", 0, err | ||
| @@ -96,38 +110,25 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura | |||||
| 96 | // that made the link may cut short. The session token carries its own | 110 | // that made the link may cut short. The session token carries its own | ||
| 97 | // expiry, so a cookie that outlives it by under a second is harmless. | 111 | // expiry, so a cookie that outlives it by under a second is harmless. | ||
| 98 | // The expiry is truncated to the second, hence the slack on the full TTL. | 112 | // The expiry is truncated to the second, hence the slack on the full TTL. | ||
| 99 | - | lifetime := app.SessionTTL | 113 | + | lifetime := usecase.SessionTTL |
| 100 | - | if d := time.Until(expiresAt); d < app.SessionTTL-time.Second { | 114 | + | if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second { |
| 101 | lifetime = d.Round(time.Second) | 115 | lifetime = d.Round(time.Second) | ||
| 102 | } | 116 | } | ||
| 103 | if lifetime < time.Second { | 117 | if lifetime < time.Second { | ||
| 104 | - | return "", 0, web.ErrLinkRejected | 118 | + | return "", 0, ports.ErrLinkRejected |
| 105 | } | 119 | } | ||
| 106 | return token, lifetime, nil | 120 | return token, lifetime, nil | ||
| 107 | } | 121 | } | ||
| 108 | 122 | ||||
| 109 | - | func (s webSessions) Verify(ctx context.Context, token string) (context.Context, bool, error) { | 123 | + | func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal, bool, error) { |
| 110 | p, err := s.Sessions.Verify(ctx, token) | 124 | p, err := s.Sessions.Verify(ctx, token) | ||
| 111 | if errors.Is(err, domain.ErrUnauthenticated) { | 125 | if errors.Is(err, domain.ErrUnauthenticated) { | ||
| 112 | - | return ctx, false, nil | 126 | + | return domain.Anonymous(), false, nil |
| 113 | } | 127 | } | ||
| 114 | if err != nil { | 128 | if err != nil { | ||
| 115 | - | return ctx, false, err | 129 | + | return domain.Anonymous(), false, err |
| 116 | } | 130 | } | ||
| 117 | - | return domain.ContextWithPrincipal(ctx, p), true, nil | 131 | + | return p, true, nil |
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | // asPrincipal runs next with p as the caller of every request, attributed to | |||
| 121 | - | // the browser's address: the API calls the web makes carry both | |||
| 122 | - | // (rpc.API.InProcessClient), so the per-client limits of the API count each | |||
| 123 | - | // visitor. Browsers are anonymous; web.Server replaces the principal with the | |||
| 124 | - | // owner's when the request carries a valid session. | |||
| 125 | - | func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler { | |||
| 126 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 127 | - | ctx := domain.ContextWithPrincipal(r.Context(), p) | |||
| 128 | - | ctx = rpc.ContextWithClient(ctx, clientlimit.ClientAddr(r, trusted)) | |||
| 129 | - | next.ServeHTTP(w, r.WithContext(ctx)) | |||
| 130 | - | }) | |||
| 131 | } | 132 | } | ||
| 132 | 133 | ||||
| 133 | // webRoutes mounts the web on the patterns it serves. | 134 | // webRoutes mounts the web on the patterns it serves. | ||
R cmd/ermite/web_commit_test.go → internal/app/web_commit_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
R cmd/ermite/web_commits_test.go → internal/app/web_commits_test.go +3 -3 renamed, 97% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
| @@ -14,8 +14,8 @@ import ( | |||||
| 14 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | 16 | ||||
| 17 | - | "bnema.dev/ermite/internal/app" | |||
| 18 | "bnema.dev/ermite/internal/domain" | 17 | "bnema.dev/ermite/internal/domain" | ||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | // diffFixture is the repository of the commit pages: one commit for each | 21 | // diffFixture is the repository of the commit pages: one commit for each | ||
| @@ -27,7 +27,7 @@ type diffFixture struct { | |||||
| 27 | // createRepo creates a public repository for the pages. | 27 | // createRepo creates a public repository for the pages. | ||
| 28 | func (h *harness) createRepo(name string, vis domain.Visibility) { | 28 | func (h *harness) createRepo(name string, vis domain.Visibility) { | ||
| 29 | h.t.Helper() | 29 | h.t.Helper() | ||
| 30 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Description: "Diffs", Visibility: vis}) | 30 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Description: "Diffs", Visibility: vis}) |
| 31 | require.NoError(h.t, err) | 31 | require.NoError(h.t, err) | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
R cmd/ermite/web_events_test.go → internal/app/web_events_test.go +10 -9 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bufio" | 6 | "bufio" | ||
| @@ -15,8 +15,9 @@ import ( | |||||
| 15 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 16 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 17 | 17 | ||||
| 18 | - | "bnema.dev/ermite/internal/app" | |||
| 19 | "bnema.dev/ermite/internal/domain" | 18 | "bnema.dev/ermite/internal/domain" | ||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | "bnema.dev/ermite/internal/usecase" | |||
| 20 | ) | 21 | ) | ||
| 21 | 22 | ||||
| 22 | // sseEvent is one event read from /-/events. | 23 | // sseEvent is one event read from /-/events. | ||
| @@ -126,7 +127,7 @@ func TestWebEventsHome(t *testing.T) { | |||||
| 126 | assert.Contains(t, ev.data, `href="/pub"`) | 127 | assert.Contains(t, ev.data, `href="/pub"`) | ||
| 127 | 128 | ||||
| 128 | // A new repository is a row; so is a repository created by a push. | 129 | // A new repository is a row; so is a repository created by a push. | ||
| 129 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic}) | 130 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic}) |
| 130 | require.NoError(t, err) | 131 | require.NoError(t, err) | ||
| 131 | ev = s.next(t, watchWait) | 132 | ev = s.next(t, watchWait) | ||
| 132 | assert.Equal(t, "home-list", ev.name) | 133 | assert.Equal(t, "home-list", ev.name) | ||
| @@ -136,7 +137,7 @@ func TestWebEventsHome(t *testing.T) { | |||||
| 136 | 137 | ||||
| 137 | // A repository that turns private is removed from the anonymous list. | 138 | // A repository that turns private is removed from the anonymous list. | ||
| 138 | priv := domain.VisibilityPrivate | 139 | priv := domain.VisibilityPrivate | ||
| 139 | - | _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), app.UpdateParams{Visibility: &priv}) | 140 | + | _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), ports.UpdateRepoParams{Visibility: &priv}) |
| 140 | require.NoError(t, err) | 141 | require.NoError(t, err) | ||
| 141 | ev = s.next(t, watchWait) | 142 | ev = s.next(t, watchWait) | ||
| 142 | assert.Equal(t, "home-list", ev.name) | 143 | assert.Equal(t, "home-list", ev.name) | ||
| @@ -162,7 +163,7 @@ func TestWebEventsRepoFragments(t *testing.T) { | |||||
| 162 | refs := h.sse("topic=repo:x&view=refs", "") | 163 | refs := h.sse("topic=repo:x&view=refs", "") | ||
| 163 | 164 | ||||
| 164 | _, _, page := h.get("/x/commits/main") | 165 | _, _, page := h.get("/x/commits/main") | ||
| 165 | - | require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner") | 166 | + | require.Contains(t, page, `<div id="commits-head" class="live-slot" role="status" data-slot>`, "the slot of the banner, a status region that stays in the page") |
| 166 | m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page) | 167 | m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page) | ||
| 167 | require.Len(t, m, 2, "the commits page is live") | 168 | require.Len(t, m, 2, "the commits page is live") | ||
| 168 | url := html.UnescapeString(m[1]) | 169 | url := html.UnescapeString(m[1]) | ||
| @@ -286,7 +287,7 @@ func TestWebEventsPrivate(t *testing.T) { | |||||
| 286 | wt := h.workTree("main", "a.txt") | 287 | wt := h.workTree("main", "a.txt") | ||
| 287 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | 288 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | ||
| 288 | desc := "secret change" | 289 | desc := "secret change" | ||
| 289 | - | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), app.UpdateParams{Description: &desc}) | 290 | + | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), ports.UpdateRepoParams{Description: &desc}) |
| 290 | require.NoError(t, err) | 291 | require.NoError(t, err) | ||
| 291 | // Made public later, it does not reveal what happened while it was | 292 | // Made public later, it does not reveal what happened while it was | ||
| 292 | // private; the update that makes it public is shown. | 293 | // private; the update that makes it public is shown. | ||
| @@ -303,7 +304,7 @@ func TestWebEventsPrivate(t *testing.T) { | |||||
| 303 | // public repository and requires its row to be the next event. | 304 | // public repository and requires its row to be the next event. | ||
| 304 | func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) { | 305 | func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) { | ||
| 305 | t.Helper() | 306 | t.Helper() | ||
| 306 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | 307 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) |
| 307 | require.NoError(t, err) | 308 | require.NoError(t, err) | ||
| 308 | ev := s.next(t, watchWait) | 309 | ev := s.next(t, watchWait) | ||
| 309 | assert.Contains(t, ev.data, `id="repo-`+name+`"`, "an event came before the sentinel: %s", ev.data) | 310 | assert.Contains(t, ev.data, `id="repo-`+name+`"`, "an event came before the sentinel: %s", ev.data) | ||
| @@ -380,7 +381,7 @@ func TestWebEventsEndOnShutdown(t *testing.T) { | |||||
| 380 | // one address cannot take every watch slot of the forge. | 381 | // one address cannot take every watch slot of the forge. | ||
| 381 | func TestWebEventsLimitPerVisitor(t *testing.T) { | 382 | func TestWebEventsLimitPerVisitor(t *testing.T) { | ||
| 382 | h := newHarness(t, harnessOpts{}) | 383 | h := newHarness(t, harnessOpts{}) | ||
| 383 | - | for range app.MaxWatchersPerClient { | 384 | + | for range usecase.MaxWatchersPerClient { |
| 384 | h.sse("topic=home", "") | 385 | h.sse("topic=home", "") | ||
| 385 | } | 386 | } | ||
| 386 | req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil) | 387 | req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil) | ||
| @@ -390,7 +391,7 @@ func TestWebEventsLimitPerVisitor(t *testing.T) { | |||||
| 390 | defer func() { _ = resp.Body.Close() }() | 391 | defer func() { _ = resp.Body.Close() }() | ||
| 391 | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | 392 | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | ||
| 392 | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | 393 | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | ||
| 393 | - | assert.Equal(t, app.MaxWatchersPerClient, h.forge.EventLog.Watchers()) | 394 | + | assert.Equal(t, usecase.MaxWatchersPerClient, h.forge.EventLog.Watchers()) |
| 394 | } | 395 | } | ||
| 395 | 396 | ||||
| 396 | // A visitor that goes away frees its watch. | 397 | // A visitor that goes away frees its watch. | ||
R cmd/ermite/web_files_test.go → internal/app/web_files_test.go +6 -6 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| @@ -22,8 +22,8 @@ import ( | |||||
| 22 | "github.com/stretchr/testify/assert" | 22 | "github.com/stretchr/testify/assert" | ||
| 23 | "github.com/stretchr/testify/require" | 23 | "github.com/stretchr/testify/require" | ||
| 24 | 24 | ||||
| 25 | - | "bnema.dev/ermite/internal/app" | |||
| 26 | "bnema.dev/ermite/internal/domain" | 25 | "bnema.dev/ermite/internal/domain" | ||
| 26 | + | "bnema.dev/ermite/internal/ports" | |||
| 27 | ) | 27 | ) | ||
| 28 | 28 | ||||
| 29 | // Trojan Source: a right-to-left override and an isolate around code. | 29 | // Trojan Source: a right-to-left override and an isolate around code. | ||
| @@ -43,7 +43,7 @@ func tinyPNG(t *testing.T) []byte { | |||||
| 43 | // tag with a slash. | 43 | // tag with a slash. | ||
| 44 | func (h *harness) webFilesRepo(name string) { | 44 | func (h *harness) webFilesRepo(name string) { | ||
| 45 | h.t.Helper() | 45 | h.t.Helper() | ||
| 46 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | 46 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ |
| 47 | Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic, | 47 | Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic, | ||
| 48 | }) | 48 | }) | ||
| 49 | require.NoError(h.t, err) | 49 | require.NoError(h.t, err) | ||
| @@ -152,7 +152,7 @@ func TestWebTreeRows(t *testing.T) { | |||||
| 152 | // that links to the directory page, which says the listing is cut. | 152 | // that links to the directory page, which says the listing is cut. | ||
| 153 | func TestWebTreeRowsTruncated(t *testing.T) { | 153 | func TestWebTreeRowsTruncated(t *testing.T) { | ||
| 154 | h := newHarness(t, harnessOpts{}) | 154 | h := newHarness(t, harnessOpts{}) | ||
| 155 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic}) | 155 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic}) |
| 156 | require.NoError(t, err) | 156 | require.NoError(t, err) | ||
| 157 | wt := h.t.TempDir() | 157 | wt := h.t.TempDir() | ||
| 158 | h.mustGit(wt, "init", "-q", "--initial-branch=main") | 158 | h.mustGit(wt, "init", "-q", "--initial-branch=main") | ||
| @@ -396,7 +396,7 @@ func TestWebFilesContentSecurity(t *testing.T) { | |||||
| 396 | h := newHarness(t, harnessOpts{}) | 396 | h := newHarness(t, harnessOpts{}) | ||
| 397 | h.webFilesRepo("files") | 397 | h.webFilesRepo("files") | ||
| 398 | 398 | ||||
| 399 | - | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | 399 | + | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" |
| 400 | for _, p := range []string{ | 400 | for _, p := range []string{ | ||
| 401 | "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md", | 401 | "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md", | ||
| 402 | "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt", | 402 | "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt", | ||
| @@ -529,7 +529,7 @@ func TestWebPaths(t *testing.T) { | |||||
| 529 | // anonymous gets the 404 of a missing repository, as the owner still | 529 | // anonymous gets the 404 of a missing repository, as the owner still | ||
| 530 | // reads it. | 530 | // reads it. | ||
| 531 | priv := domain.VisibilityPrivate | 531 | priv := domain.VisibilityPrivate | ||
| 532 | - | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), app.UpdateParams{Visibility: &priv}) | 532 | + | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), ports.UpdateRepoParams{Visibility: &priv}) |
| 533 | require.NoError(t, err) | 533 | require.NoError(t, err) | ||
| 534 | for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} { | 534 | for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} { | ||
| 535 | p := b.get(path, hdr) | 535 | p := b.get(path, hdr) | ||
R cmd/ermite/web_mr_test.go → internal/app/web_mr_test.go +6 -6 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "html" | 6 | "html" | ||
| @@ -13,8 +13,8 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | - | "bnema.dev/ermite/internal/app" | |||
| 17 | "bnema.dev/ermite/internal/domain" | 16 | "bnema.dev/ermite/internal/domain" | ||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | // mrRepo is a public repository "proj" with README.md on main and a branch | 20 | // mrRepo is a public repository "proj" with README.md on main and a branch | ||
| @@ -67,7 +67,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 67 | anon := b.get("/proj/compare/feat/search", nil) | 67 | anon := b.get("/proj/compare/feat/search", nil) | ||
| 68 | require.Equal(t, 200, anon.status, anon.body) | 68 | require.Equal(t, 200, anon.status, anon.body) | ||
| 69 | assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control")) | 69 | assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control")) | ||
| 70 | - | assert.Empty(t, anon.hdr.Values("Vary")) | 70 | + | assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary")) |
| 71 | assert.Contains(t, anon.body, "feat/search") | 71 | assert.Contains(t, anon.body, "feat/search") | ||
| 72 | assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit") | 72 | assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit") | ||
| 73 | assert.NotContains(t, anon.body, "<form class=\"cform create\"") | 73 | assert.NotContains(t, anon.body, "<form class=\"cform create\"") | ||
| @@ -129,7 +129,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 129 | mr := b.get("/proj/merge-requests/1", nil) | 129 | mr := b.get("/proj/merge-requests/1", nil) | ||
| 130 | require.Equal(t, 200, mr.status, mr.body) | 130 | require.Equal(t, 200, mr.status, mr.body) | ||
| 131 | assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control")) | 131 | assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control")) | ||
| 132 | - | assert.Empty(t, mr.hdr.Values("Vary")) | 132 | + | assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary")) |
| 133 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add <b>search</b></h1>") | 133 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add <b>search</b></h1>") | ||
| 134 | assert.NotContains(t, mr.body, "<b>search</b>") | 134 | assert.NotContains(t, mr.body, "<b>search</b>") | ||
| 135 | assert.Contains(t, mr.body, "feat/search") | 135 | assert.Contains(t, mr.body, "feat/search") | ||
| @@ -466,7 +466,7 @@ func TestWebMergeRequestLimitIsFriendly(t *testing.T) { | |||||
| 466 | token := r.signIn(t) | 466 | token := r.signIn(t) | ||
| 467 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t) | 467 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t) | ||
| 468 | for i := 0; i < 1000; i++ { | 468 | for i := 0; i < 1000; i++ { | ||
| 469 | - | _, err := r.forge.MergeRequests.AddComment(r.ctx, domain.Root(), r.name("proj"), 1, app.AddCommentParams{Text: "n"}) | 469 | + | _, err := r.forge.MergeRequests.AddComment(r.ctx, domain.Root(), r.name("proj"), 1, ports.AddCommentParams{Text: "n"}) |
| 470 | require.NoError(t, err) | 470 | require.NoError(t, err) | ||
| 471 | } | 471 | } | ||
| 472 | full := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"one more"}}) | 472 | full := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"one more"}}) | ||
| @@ -511,7 +511,7 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||||
| 511 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | 511 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | ||
| 512 | ev := list.next(t, watchWait) | 512 | ev := list.next(t, watchWait) | ||
| 513 | assert.Equal(t, "mr-notice", ev.name) | 513 | assert.Equal(t, "mr-notice", ev.name) | ||
| 514 | - | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-banner" role="status">`) | 514 | + | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) |
| 515 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | 515 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | ||
| 516 | assert.NotContains(t, ev.data, "Secret") | 516 | assert.NotContains(t, ev.data, "Secret") | ||
| 517 | 517 | ||||
R cmd/ermite/web_session_test.go → internal/app/web_session_test.go +49 -11 renamed, 92% similar
| @@ -1,11 +1,12 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | + | "net/netip" | |||
| 9 | "net/url" | 10 | "net/url" | ||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "testing" | 12 | "testing" | ||
| @@ -18,8 +19,8 @@ import ( | |||||
| 18 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 19 | 20 | ||||
| 20 | pb "bnema.dev/ermite-api/gen/ermite" | 21 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 21 | - | "bnema.dev/ermite/internal/app" | |||
| 22 | "bnema.dev/ermite/internal/domain" | 22 | "bnema.dev/ermite/internal/domain" | ||
| 23 | + | "bnema.dev/ermite/internal/ports" | |||
| 23 | ) | 24 | ) | ||
| 24 | 25 | ||||
| 25 | const ( | 26 | const ( | ||
| @@ -115,7 +116,7 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||||
| 115 | assert.Contains(t, home.body, "pub") | 116 | assert.Contains(t, home.body, "pub") | ||
| 116 | assert.NotContains(t, home.body, "secret") | 117 | assert.NotContains(t, home.body, "secret") | ||
| 117 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | 118 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | ||
| 118 | - | assert.Empty(t, home.hdr.Values("Vary")) | 119 | + | assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") |
| 119 | assert.NotContains(t, home.body, `action="/-/logout"`) | 120 | assert.NotContains(t, home.body, `action="/-/logout"`) | ||
| 120 | assert.Equal(t, 404, b.get("/secret", nil).status) | 121 | assert.Equal(t, 404, b.get("/secret", nil).status) | ||
| 121 | 122 | ||||
| @@ -310,7 +311,7 @@ func TestWebRevokingAnOwnerTokenSignsEveryoneOut(t *testing.T) { | |||||
| 310 | 311 | ||||
| 311 | // The cap: a link made by a token that expires in an hour gives a cookie, | 312 | // The cap: a link made by a token that expires in an hour gives a cookie, | ||
| 312 | // and a session, of an hour. | 313 | // and a session, of an hour. | ||
| 313 | - | soon, err := c.forge.Tokens.Create(c.ctx, domain.Root(), app.CreateTokenParams{ | 314 | + | soon, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{ |
| 314 | Label: "soon", Scopes: domain.AllScopes(), ExpiresAt: time.Now().Add(time.Hour), | 315 | Label: "soon", Scopes: domain.AllScopes(), ExpiresAt: time.Now().Add(time.Hour), | ||
| 315 | }) | 316 | }) | ||
| 316 | require.NoError(t, err) | 317 | require.NoError(t, err) | ||
| @@ -505,14 +506,22 @@ func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) { | |||||
| 505 | assert.NotContains(t, logs, string(key)) | 506 | assert.NotContains(t, logs, string(key)) | ||
| 506 | } | 507 | } | ||
| 507 | 508 | ||||
| 508 | - | // The owner principal is what the API sees from a signed-in page, and is | 509 | + | // The owner principal is what the use cases see from a signed-in page, and is |
| 509 | - | // labelled "owner". | 510 | + | // labelled "owner": the use case logs it with a write the page made. |
| 510 | func TestWebOwnerIsLabelledInLogs(t *testing.T) { | 511 | func TestWebOwnerIsLabelledInLogs(t *testing.T) { | ||
| 511 | - | c := newCLIHarness(t) | 512 | + | r := newMRRepo(t, domain.VisibilityPublic) |
| 512 | - | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | 513 | + | token := r.signIn(t) |
| 513 | - | token := c.signIn(t) | 514 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) |
| 514 | - | browser{c.harness}.get("/secret", as(token)) | 515 | + | b := browser{r.harness} |
| 515 | - | assert.Contains(t, c.logs.String(), `"principal":"owner"`) | 516 | + | res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"from the page"}}) |
| 517 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |||
| 518 | + | var line string | |||
| 519 | + | for _, l := range strings.Split(r.logs.String(), "\n") { | |||
| 520 | + | if strings.Contains(l, `"comment added"`) { | |||
| 521 | + | line = l | |||
| 522 | + | } | |||
| 523 | + | } | |||
| 524 | + | assert.Contains(t, line, `"principal":"owner"`) | |||
| 516 | } | 525 | } | ||
| 517 | 526 | ||||
| 518 | // GO-004: a Secure cookie is kept by browsers only from https (or a loopback | 527 | // GO-004: a Secure cookie is kept by browsers only from https (or a loopback | ||
| @@ -558,3 +567,32 @@ func TestStartupWarnsWhenSignInCannotWork(t *testing.T) { | |||||
| 558 | warnSessionsNeedHTTPS(ctx, "http://forge.example") | 567 | warnSessionsNeedHTTPS(ctx, "http://forge.example") | ||
| 559 | assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL") | 568 | assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL") | ||
| 560 | } | 569 | } | ||
| 570 | + | ||||
| 571 | + | func TestWarnSharedClientKey(t *testing.T) { | |||
| 572 | + | t.Parallel() | |||
| 573 | + | proxies := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} | |||
| 574 | + | tests := []struct { | |||
| 575 | + | name string | |||
| 576 | + | trusted []netip.Prefix | |||
| 577 | + | cap int | |||
| 578 | + | warns bool | |||
| 579 | + | }{ | |||
| 580 | + | {"no proxy, cap active", nil, 4, true}, | |||
| 581 | + | {"proxies trusted", proxies, 4, false}, | |||
| 582 | + | {"no proxy, no cap", nil, 0, false}, | |||
| 583 | + | } | |||
| 584 | + | for _, tt := range tests { | |||
| 585 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 586 | + | t.Parallel() | |||
| 587 | + | var buf syncBuffer | |||
| 588 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf})) | |||
| 589 | + | warnSharedClientKey(ctx, tt.trusted, tt.cap) | |||
| 590 | + | if !tt.warns { | |||
| 591 | + | assert.Empty(t, buf.String()) | |||
| 592 | + | return | |||
| 593 | + | } | |||
| 594 | + | assert.Contains(t, buf.String(), `"level":"warn"`) | |||
| 595 | + | assert.Contains(t, buf.String(), "ERMITE_TRUSTED_PROXIES") | |||
| 596 | + | }) | |||
| 597 | + | } | |||
| 598 | + | } | |||
R cmd/ermite/web_test.go → internal/app/web_test.go +6 -6 renamed, 96% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package main | 3 | + | package app |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "flag" | 6 | "flag" | ||
| @@ -16,8 +16,8 @@ import ( | |||||
| 16 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 17 | "github.com/stretchr/testify/require" | 17 | "github.com/stretchr/testify/require" | ||
| 18 | 18 | ||||
| 19 | - | "bnema.dev/ermite/internal/app" | |||
| 20 | "bnema.dev/ermite/internal/domain" | 19 | "bnema.dev/ermite/internal/domain" | ||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | ) | 21 | ) | ||
| 22 | 22 | ||||
| 23 | var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests") | 23 | var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests") | ||
| @@ -30,7 +30,7 @@ const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) | |||||
| 30 | // branches (one with a slash), two tags and a description. | 30 | // branches (one with a slash), two tags and a description. | ||
| 31 | func (h *harness) webRepo(name string) { | 31 | func (h *harness) webRepo(name string) { | ||
| 32 | h.t.Helper() | 32 | h.t.Helper() | ||
| 33 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | 33 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ |
| 34 | Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic, | 34 | Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic, | ||
| 35 | }) | 35 | }) | ||
| 36 | require.NoError(h.t, err) | 36 | require.NoError(h.t, err) | ||
| @@ -100,7 +100,7 @@ func golden(t *testing.T, name, got string) { | |||||
| 100 | return | 100 | return | ||
| 101 | } | 101 | } | ||
| 102 | want, err := os.ReadFile(path) | 102 | want, err := os.ReadFile(path) | ||
| 103 | - | require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update") | 103 | + | require.NoError(t, err, "run: go test ./internal/app -run TestWebGolden -update") |
| 104 | assert.Equal(t, string(want), got, name) | 104 | assert.Equal(t, string(want), got, name) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| @@ -162,7 +162,7 @@ func TestWebPrivateRepoIsInvisible(t *testing.T) { | |||||
| 162 | // AC4, end to end: a README that attacks the page is neutralised. | 162 | // AC4, end to end: a README that attacks the page is neutralised. | ||
| 163 | func TestWebReadmeIsNeutralised(t *testing.T) { | 163 | func TestWebReadmeIsNeutralised(t *testing.T) { | ||
| 164 | h := newHarness(t, harnessOpts{}) | 164 | h := newHarness(t, harnessOpts{}) | ||
| 165 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic}) | 165 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic}) |
| 166 | require.NoError(t, err) | 166 | require.NoError(t, err) | ||
| 167 | wt := h.t.TempDir() | 167 | wt := h.t.TempDir() | ||
| 168 | h.mustGit(wt, "init", "-q", "--initial-branch=main") | 168 | h.mustGit(wt, "init", "-q", "--initial-branch=main") | ||
| @@ -207,7 +207,7 @@ func TestWebContentSecurity(t *testing.T) { | |||||
| 207 | h.webRepo("demo") | 207 | h.webRepo("demo") | ||
| 208 | h.seed("quiet", domain.VisibilityPublic) | 208 | h.seed("quiet", domain.VisibilityPublic) | ||
| 209 | 209 | ||||
| 210 | - | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | 210 | + | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" |
| 211 | for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} { | 211 | for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} { | ||
| 212 | status, hdr, body := h.get(p) | 212 | status, hdr, body := h.get(p) | ||
| 213 | assert.Contains(t, []int{200, 404}, status, p) | 213 | assert.Contains(t, []int{200, 404}, status, p) | ||
A internal/boundary/boundary_test.go +341 -0
| @@ -0,0 +1,341 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package boundary_test enforces the import rules between the layers of | |||
| 4 | + | // ermite. The module root holds no Go package (the binary lives in cmd/ermite), | |||
| 5 | + | // so the check lives in this test-only package and walks the module from its | |||
| 6 | + | // root. It parses import clauses only; it never loads packages. | |||
| 7 | + | package boundary_test | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "fmt" | |||
| 11 | + | "go/parser" | |||
| 12 | + | "go/token" | |||
| 13 | + | "io/fs" | |||
| 14 | + | "path/filepath" | |||
| 15 | + | "sort" | |||
| 16 | + | "strconv" | |||
| 17 | + | "strings" | |||
| 18 | + | "testing" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | const modulePath = "bnema.dev/ermite" | |||
| 22 | + | ||||
| 23 | + | // apiGenPath is the generated code of the API contract (protobuf and Connect), | |||
| 24 | + | // a module of its own. | |||
| 25 | + | const apiGenPath = "bnema.dev/ermite-api/gen" | |||
| 26 | + | ||||
| 27 | + | // moduleRoot is the module root relative to this package's directory. | |||
| 28 | + | const moduleRoot = "../.." | |||
| 29 | + | ||||
| 30 | + | type packageLayer string | |||
| 31 | + | ||||
| 32 | + | const ( | |||
| 33 | + | layerCommand packageLayer = "command" | |||
| 34 | + | layerApp packageLayer = "app" | |||
| 35 | + | layerUsecase packageLayer = "usecase" | |||
| 36 | + | layerAdapter packageLayer = "adapter" | |||
| 37 | + | layerClientLimit packageLayer = "clientlimit" | |||
| 38 | + | layerConfig packageLayer = "config" | |||
| 39 | + | layerPorts packageLayer = "ports" | |||
| 40 | + | layerDomain packageLayer = "domain" | |||
| 41 | + | layerGenerated packageLayer = "generated" | |||
| 42 | + | layerScript packageLayer = "script" | |||
| 43 | + | layerBoundary packageLayer = "boundary" | |||
| 44 | + | layerTestSupport packageLayer = "test-support" | |||
| 45 | + | ) | |||
| 46 | + | ||||
| 47 | + | // productionDependencies lists, per layer, the layers its production files | |||
| 48 | + | // may import. | |||
| 49 | + | // | |||
| 50 | + | // - cmd/ermite is the entry point: it only calls internal/app. | |||
| 51 | + | // - internal/app is the composition root and the CLI: it wires everything. | |||
| 52 | + | // - internal/usecase holds the use cases: domain and ports only. | |||
| 53 | + | // - adapters implement ports and never import use cases or app; the | |||
| 54 | + | // per-package rule in packageImportDenied also keeps them from importing | |||
| 55 | + | // each other, and keeps the web adapter off the generated API code: like | |||
| 56 | + | // every other adapter it reads and writes through the ports (ADR 0020). | |||
| 57 | + | // - internal/clientlimit is infrastructure shared by the HTTP adapters. | |||
| 58 | + | // - internal/config parses the environment into plain values. | |||
| 59 | + | var productionDependencies = map[packageLayer]map[packageLayer]bool{ | |||
| 60 | + | layerCommand: {layerApp: true}, | |||
| 61 | + | layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |||
| 62 | + | layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true}, | |||
| 63 | + | layerAdapter: {layerAdapter: true, layerClientLimit: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |||
| 64 | + | layerClientLimit: {layerPorts: true}, | |||
| 65 | + | layerConfig: {}, | |||
| 66 | + | layerPorts: {layerDomain: true}, | |||
| 67 | + | layerDomain: {layerDomain: true}, | |||
| 68 | + | layerGenerated: {layerGenerated: true}, | |||
| 69 | + | layerScript: {}, | |||
| 70 | + | layerBoundary: {}, | |||
| 71 | + | layerTestSupport: {layerPorts: true, layerDomain: true}, | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestImportBoundaries(t *testing.T) { | |||
| 75 | + | violations, packages := inspectRepositoryDependencies(t) | |||
| 76 | + | if len(violations) != 0 { | |||
| 77 | + | t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n")) | |||
| 78 | + | } | |||
| 79 | + | for _, want := range []string{"cmd/ermite", "internal/app", "internal/usecase", "internal/ports", "internal/domain"} { | |||
| 80 | + | if _, ok := packages[modulePath+"/"+want]; !ok { | |||
| 81 | + | t.Fatalf("package inventory misses %s: the walk is not reading the module", want) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // The generated API code is outside the module: the walk would skip it. The | |||
| 87 | + | // web adapter may not import it in production code, whichever package of it. | |||
| 88 | + | func TestWebAdapterRejectsAPIGeneratedCode(t *testing.T) { | |||
| 89 | + | tests := []struct { | |||
| 90 | + | name, source, target string | |||
| 91 | + | testFile bool | |||
| 92 | + | want bool | |||
| 93 | + | }{ | |||
| 94 | + | {"web messages", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", false, false}, | |||
| 95 | + | {"web connect", modulePath + "/internal/adapters/web", apiGenPath + "/ermite/ermiteconnect", false, false}, | |||
| 96 | + | {"web subpackage", modulePath + "/internal/adapters/web/sub", apiGenPath + "/ermite", false, false}, | |||
| 97 | + | {"web test file", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", true, true}, | |||
| 98 | + | {"rpc adapter", modulePath + "/internal/adapters/rpc", apiGenPath + "/ermite", false, true}, | |||
| 99 | + | {"client adapter", modulePath + "/internal/adapters/client", apiGenPath + "/ermite/ermiteconnect", false, true}, | |||
| 100 | + | {"other module of the API", modulePath + "/internal/adapters/web", "bnema.dev/ermite-api/other", false, true}, | |||
| 101 | + | {"web other external package", modulePath + "/internal/adapters/web", "github.com/a-h/templ", false, true}, | |||
| 102 | + | {"a package whose name only starts alike", modulePath + "/internal/adapters/web", apiGenPath + "x/y", false, true}, | |||
| 103 | + | } | |||
| 104 | + | for _, tt := range tests { | |||
| 105 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 106 | + | if got := !externalImportDenied(tt.source, tt.target, tt.testFile); got != tt.want { | |||
| 107 | + | t.Fatalf("externalImportDenied(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, !got, !tt.want) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestImportBoundaryFixtures(t *testing.T) { | |||
| 114 | + | p := func(s string) string { return modulePath + "/" + s } | |||
| 115 | + | tests := []struct { | |||
| 116 | + | name, source, target string | |||
| 117 | + | testFile bool | |||
| 118 | + | want bool | |||
| 119 | + | }{ | |||
| 120 | + | {"command accepts app", p("cmd/ermite"), p("internal/app"), false, true}, | |||
| 121 | + | {"command rejects usecase", p("cmd/ermite"), p("internal/usecase"), false, false}, | |||
| 122 | + | {"command rejects adapter", p("cmd/ermite"), p("internal/adapters/web"), false, false}, | |||
| 123 | + | {"command rejects domain", p("cmd/ermite"), p("internal/domain"), false, false}, | |||
| 124 | + | {"app accepts usecase", p("internal/app"), p("internal/usecase"), false, true}, | |||
| 125 | + | {"app accepts adapter", p("internal/app"), p("internal/adapters/gitcli"), false, true}, | |||
| 126 | + | {"app accepts config", p("internal/app"), p("internal/config"), false, true}, | |||
| 127 | + | {"app accepts clientlimit", p("internal/app"), p("internal/clientlimit"), false, true}, | |||
| 128 | + | {"app rejects command", p("internal/app"), p("cmd/ermite"), false, false}, | |||
| 129 | + | {"app test may import command", p("internal/app"), p("cmd/ermite"), true, true}, | |||
| 130 | + | {"usecase accepts domain", p("internal/usecase"), p("internal/domain"), false, true}, | |||
| 131 | + | {"usecase accepts ports", p("internal/usecase"), p("internal/ports"), false, true}, | |||
| 132 | + | {"usecase rejects adapter", p("internal/usecase"), p("internal/adapters/kvstore"), false, false}, | |||
| 133 | + | {"usecase rejects app", p("internal/usecase"), p("internal/app"), false, false}, | |||
| 134 | + | {"usecase rejects config", p("internal/usecase"), p("internal/config"), false, false}, | |||
| 135 | + | {"usecase rejects clientlimit", p("internal/usecase"), p("internal/clientlimit"), false, false}, | |||
| 136 | + | {"usecase test may import adapter", p("internal/usecase"), p("internal/adapters/kvstore"), true, true}, | |||
| 137 | + | {"adapter accepts ports", p("internal/adapters/rpc"), p("internal/ports"), false, true}, | |||
| 138 | + | {"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true}, | |||
| 139 | + | {"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true}, | |||
| 140 | + | {"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true}, | |||
| 141 | + | {"adapter accepts generated", p("internal/adapters/rpc"), p("gen/ermite"), false, true}, | |||
| 142 | + | {"web rejects generated", p("internal/adapters/web"), p("gen/ermite"), false, false}, | |||
| 143 | + | {"web rejects generated connect", p("internal/adapters/web"), p("gen/ermite/ermiteconnect"), false, false}, | |||
| 144 | + | {"web subpackage rejects generated", p("internal/adapters/web/sub"), p("gen/ermite"), false, false}, | |||
| 145 | + | {"web test may import ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), true, true}, | |||
| 146 | + | {"web production rejects ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), false, false}, | |||
| 147 | + | {"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false}, | |||
| 148 | + | {"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false}, | |||
| 149 | + | {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false}, | |||
| 150 | + | {"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false}, | |||
| 151 | + | {"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true}, | |||
| 152 | + | {"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true}, | |||
| 153 | + | {"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false}, | |||
| 154 | + | {"config rejects domain", p("internal/config"), p("internal/domain"), false, false}, | |||
| 155 | + | {"ports accepts domain", p("internal/ports"), p("internal/domain"), false, true}, | |||
| 156 | + | {"ports rejects usecase", p("internal/ports"), p("internal/usecase"), false, false}, | |||
| 157 | + | {"ports rejects adapter", p("internal/ports"), p("internal/adapters/web"), false, false}, | |||
| 158 | + | {"ports mocks accept ports", p("internal/ports/mocks"), p("internal/ports"), false, true}, | |||
| 159 | + | {"ports mocks reject usecase", p("internal/ports/mocks"), p("internal/usecase"), false, false}, | |||
| 160 | + | {"reader conformance accepts ports", p("internal/readertest"), p("internal/ports"), false, true}, | |||
| 161 | + | {"reader conformance rejects adapter", p("internal/readertest"), p("internal/adapters/gitcli"), false, false}, | |||
| 162 | + | {"domain accepts domain", p("internal/domain"), p("internal/domain/x"), false, true}, | |||
| 163 | + | {"domain rejects ports", p("internal/domain"), p("internal/ports"), false, false}, | |||
| 164 | + | {"domain test may import anything", p("internal/domain"), p("internal/ports"), true, true}, | |||
| 165 | + | } | |||
| 166 | + | for _, tt := range tests { | |||
| 167 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 168 | + | got, err := dependencyAllowed(tt.source, tt.target, tt.testFile) | |||
| 169 | + | if err != nil { | |||
| 170 | + | t.Fatalf("dependencyAllowed: %v", err) | |||
| 171 | + | } | |||
| 172 | + | if got != tt.want { | |||
| 173 | + | t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want) | |||
| 174 | + | } | |||
| 175 | + | }) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) { | |||
| 180 | + | t.Helper() | |||
| 181 | + | var violations []string | |||
| 182 | + | packages := make(map[string]struct{}) | |||
| 183 | + | fset := token.NewFileSet() | |||
| 184 | + | err := filepath.WalkDir(moduleRoot, func(path string, entry fs.DirEntry, walkErr error) error { | |||
| 185 | + | if walkErr != nil { | |||
| 186 | + | return walkErr | |||
| 187 | + | } | |||
| 188 | + | if entry.IsDir() { | |||
| 189 | + | name := entry.Name() | |||
| 190 | + | if name == ".git" || name == ".worktrees" || name == "vendor" || name == "testdata" || name == "node_modules" { | |||
| 191 | + | return filepath.SkipDir | |||
| 192 | + | } | |||
| 193 | + | return nil | |||
| 194 | + | } | |||
| 195 | + | if !strings.HasSuffix(path, ".go") { | |||
| 196 | + | return nil | |||
| 197 | + | } | |||
| 198 | + | rel, err := filepath.Rel(moduleRoot, path) | |||
| 199 | + | if err != nil { | |||
| 200 | + | return err | |||
| 201 | + | } | |||
| 202 | + | source := importPathForFile(rel) | |||
| 203 | + | packages[source] = struct{}{} | |||
| 204 | + | if _, err := classifyPackage(source); err != nil { | |||
| 205 | + | violations = append(violations, rel+": "+err.Error()) | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return err | |||
| 211 | + | } | |||
| 212 | + | testFile := strings.HasSuffix(path, "_test.go") | |||
| 213 | + | for _, spec := range file.Imports { | |||
| 214 | + | target, err := strconv.Unquote(spec.Path.Value) | |||
| 215 | + | if err != nil { | |||
| 216 | + | continue | |||
| 217 | + | } | |||
| 218 | + | if externalImportDenied(source, target, testFile) { | |||
| 219 | + | violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target)) | |||
| 220 | + | continue | |||
| 221 | + | } | |||
| 222 | + | if target != modulePath && !strings.HasPrefix(target, modulePath+"/") { | |||
| 223 | + | continue | |||
| 224 | + | } | |||
| 225 | + | allowed, err := dependencyAllowed(source, target, testFile) | |||
| 226 | + | if err != nil { | |||
| 227 | + | violations = append(violations, fmt.Sprintf("%s: %v", rel, err)) | |||
| 228 | + | continue | |||
| 229 | + | } | |||
| 230 | + | if !allowed { | |||
| 231 | + | violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target)) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | return nil | |||
| 235 | + | }) | |||
| 236 | + | if err != nil { | |||
| 237 | + | t.Fatalf("walking module: %v", err) | |||
| 238 | + | } | |||
| 239 | + | sort.Strings(violations) | |||
| 240 | + | return violations, packages | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // dependencyAllowed reports whether a file of package source may import | |||
| 244 | + | // target. Test files are exempt, so that composition tests can wire adapters | |||
| 245 | + | // and use cases together; their packages must still be classified. | |||
| 246 | + | func dependencyAllowed(source, target string, testFile bool) (bool, error) { | |||
| 247 | + | sourceLayer, err := classifyPackage(source) | |||
| 248 | + | if err != nil { | |||
| 249 | + | return false, err | |||
| 250 | + | } | |||
| 251 | + | targetLayer, err := classifyPackage(target) | |||
| 252 | + | if err != nil { | |||
| 253 | + | return false, err | |||
| 254 | + | } | |||
| 255 | + | if testFile { | |||
| 256 | + | return true, nil | |||
| 257 | + | } | |||
| 258 | + | if packageImportDenied(source, target) { | |||
| 259 | + | return false, nil | |||
| 260 | + | } | |||
| 261 | + | return productionDependencies[sourceLayer][targetLayer], nil | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | // externalImportDenied reports whether a file of package source may not import | |||
| 265 | + | // target, a package outside the module. The only such rule: the production | |||
| 266 | + | // code of the web adapter does not import the generated API code | |||
| 267 | + | // (apiGenPath), so that it goes through the ports. Test files are exempt, like | |||
| 268 | + | // for every rule here. | |||
| 269 | + | func externalImportDenied(source, target string, testFile bool) bool { | |||
| 270 | + | if testFile || !under(target, apiGenPath) { | |||
| 271 | + | return false | |||
| 272 | + | } | |||
| 273 | + | return under(source, modulePath+"/internal/adapters/web") | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // packageImportDenied narrows the layer rules to packages: an adapter may | |||
| 277 | + | // import only packages under its own directory, never a sibling adapter; and | |||
| 278 | + | // the web adapter may not import the generated API code at all (protobuf, | |||
| 279 | + | // Connect): it goes through the ports, as the other adapters that serve the | |||
| 280 | + | // use cases do. | |||
| 281 | + | func packageImportDenied(source, target string) bool { | |||
| 282 | + | const adapters = modulePath + "/internal/adapters/" | |||
| 283 | + | if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) { | |||
| 284 | + | return adapterRoot(source) != adapterRoot(target) | |||
| 285 | + | } | |||
| 286 | + | if strings.HasPrefix(source, adapters) && adapterRoot(source) == "web" { | |||
| 287 | + | return under(target, modulePath+"/gen") | |||
| 288 | + | } | |||
| 289 | + | return false | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // adapterRoot is the first path element under internal/adapters. | |||
| 293 | + | func adapterRoot(path string) string { | |||
| 294 | + | rest := strings.TrimPrefix(path, modulePath+"/internal/adapters/") | |||
| 295 | + | root, _, _ := strings.Cut(rest, "/") | |||
| 296 | + | return root | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func under(path, root string) bool { | |||
| 300 | + | return path == root || strings.HasPrefix(path, root+"/") | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func classifyPackage(path string) (packageLayer, error) { | |||
| 304 | + | p := func(s string) string { return modulePath + "/" + s } | |||
| 305 | + | switch { | |||
| 306 | + | case under(path, p("cmd")): | |||
| 307 | + | return layerCommand, nil | |||
| 308 | + | case under(path, p("scripts")): | |||
| 309 | + | return layerScript, nil | |||
| 310 | + | case under(path, p("internal/boundary")): | |||
| 311 | + | return layerBoundary, nil | |||
| 312 | + | case under(path, p("internal/app")): | |||
| 313 | + | return layerApp, nil | |||
| 314 | + | case under(path, p("internal/usecase")): | |||
| 315 | + | return layerUsecase, nil | |||
| 316 | + | case path == p("internal/ports/mocks"), under(path, p("internal/readertest")): | |||
| 317 | + | return layerTestSupport, nil | |||
| 318 | + | case under(path, p("internal/adapters")): | |||
| 319 | + | return layerAdapter, nil | |||
| 320 | + | case under(path, p("internal/clientlimit")): | |||
| 321 | + | return layerClientLimit, nil | |||
| 322 | + | case under(path, p("internal/config")): | |||
| 323 | + | return layerConfig, nil | |||
| 324 | + | case path == p("internal/ports"): | |||
| 325 | + | return layerPorts, nil | |||
| 326 | + | case under(path, p("internal/domain")): | |||
| 327 | + | return layerDomain, nil | |||
| 328 | + | case under(path, p("gen")): | |||
| 329 | + | return layerGenerated, nil | |||
| 330 | + | default: | |||
| 331 | + | return "", fmt.Errorf("unclassified package %q", path) | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func importPathForFile(rel string) string { | |||
| 336 | + | dir := filepath.ToSlash(filepath.Dir(rel)) | |||
| 337 | + | if dir == "." { | |||
| 338 | + | return modulePath | |||
| 339 | + | } | |||
| 340 | + | return modulePath + "/" + dir | |||
| 341 | + | } | |||
M internal/clientlimit/limiter.go +4 -0
| @@ -16,6 +16,8 @@ import ( | |||||
| 16 | "time" | 16 | "time" | ||
| 17 | 17 | ||||
| 18 | "github.com/bnema/zerowrap" | 18 | "github.com/bnema/zerowrap" | ||
| 19 | + | ||||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | ) | 21 | ) | ||
| 20 | 22 | ||||
| 21 | // Limiter defaults: 10 failed authentications in 10 minutes block the client | 23 | // Limiter defaults: 10 failed authentications in 10 minutes block the client | ||
| @@ -54,6 +56,8 @@ type Limiter struct { | |||||
| 54 | lastSweep time.Time | 56 | lastSweep time.Time | ||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
| 59 | + | var _ ports.FailureLimiter = (*Limiter)(nil) | |||
| 60 | + | ||||
| 57 | // NewLimiter builds a limiter. now defaults to time.Now. | 61 | // NewLimiter builds a limiter. now defaults to time.Now. | ||
| 58 | func NewLimiter(now func() time.Time) *Limiter { | 62 | func NewLimiter(now func() time.Time) *Limiter { | ||
| 59 | if now == nil { | 63 | if now == nil { | ||
M internal/clientlimit/limiter_test.go +9 -0
| @@ -23,6 +23,7 @@ type fakeClock struct{ t time.Time } | |||||
| 23 | func (c *fakeClock) now() time.Time { return c.t } | 23 | func (c *fakeClock) now() time.Time { return c.t } | ||
| 24 | 24 | ||||
| 25 | func TestLimiter(t *testing.T) { | 25 | func TestLimiter(t *testing.T) { | ||
| 26 | + | t.Parallel() | |||
| 26 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 27 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 27 | l := clientlimit.NewLimiter(clk.now) | 28 | l := clientlimit.NewLimiter(clk.now) | ||
| 28 | 29 | ||||
| @@ -52,6 +53,7 @@ func TestLimiter(t *testing.T) { | |||||
| 52 | } | 53 | } | ||
| 53 | 54 | ||||
| 54 | func TestLimiterSlidingWindow(t *testing.T) { | 55 | func TestLimiterSlidingWindow(t *testing.T) { | ||
| 56 | + | t.Parallel() | |||
| 55 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 57 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 56 | l := clientlimit.NewLimiter(clk.now) | 58 | l := clientlimit.NewLimiter(clk.now) | ||
| 57 | for range 5 { | 59 | for range 5 { | ||
| @@ -71,6 +73,7 @@ func TestLimiterSlidingWindow(t *testing.T) { | |||||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
| 73 | func TestLimiterOneAddressStaysSmall(t *testing.T) { | 75 | func TestLimiterOneAddressStaysSmall(t *testing.T) { | ||
| 76 | + | t.Parallel() | |||
| 74 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 77 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 75 | l := clientlimit.NewLimiter(clk.now) | 78 | l := clientlimit.NewLimiter(clk.now) | ||
| 76 | for range 5000 { | 79 | for range 5000 { | ||
| @@ -82,6 +85,7 @@ func TestLimiterOneAddressStaysSmall(t *testing.T) { | |||||
| 82 | } | 85 | } | ||
| 83 | 86 | ||||
| 84 | func TestLimiterBoundsMemory(t *testing.T) { | 87 | func TestLimiterBoundsMemory(t *testing.T) { | ||
| 88 | + | t.Parallel() | |||
| 85 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 89 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 86 | l := clientlimit.NewLimiter(clk.now) | 90 | l := clientlimit.NewLimiter(clk.now) | ||
| 87 | for i := range clientlimit.MaxTracked + 5000 { | 91 | for i := range clientlimit.MaxTracked + 5000 { | ||
| @@ -103,6 +107,7 @@ func TestLimiterBoundsMemory(t *testing.T) { | |||||
| 103 | } | 107 | } | ||
| 104 | 108 | ||||
| 105 | func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | 109 | func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | ||
| 110 | + | t.Parallel() | |||
| 106 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 111 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 107 | l := clientlimit.NewLimiter(clk.now) | 112 | l := clientlimit.NewLimiter(clk.now) | ||
| 108 | for i := range 100 { | 113 | for i := range 100 { | ||
| @@ -135,6 +140,7 @@ func BenchmarkLimiterFailAtMaxTracked(b *testing.B) { | |||||
| 135 | } | 140 | } | ||
| 136 | 141 | ||||
| 137 | func TestLimiterGroupsIPv6By64(t *testing.T) { | 142 | func TestLimiterGroupsIPv6By64(t *testing.T) { | ||
| 143 | + | t.Parallel() | |||
| 138 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 144 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 139 | l := clientlimit.NewLimiter(clk.now) | 145 | l := clientlimit.NewLimiter(clk.now) | ||
| 140 | 146 | ||||
| @@ -155,6 +161,7 @@ func TestLimiterGroupsIPv6By64(t *testing.T) { | |||||
| 155 | } | 161 | } | ||
| 156 | 162 | ||||
| 157 | func TestLimiterIPv4StaysPerAddress(t *testing.T) { | 163 | func TestLimiterIPv4StaysPerAddress(t *testing.T) { | ||
| 164 | + | t.Parallel() | |||
| 158 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 165 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 159 | l := clientlimit.NewLimiter(clk.now) | 166 | l := clientlimit.NewLimiter(clk.now) | ||
| 160 | for range clientlimit.MaxFailures { | 167 | for range clientlimit.MaxFailures { | ||
| @@ -170,6 +177,7 @@ func TestLimiterIPv4StaysPerAddress(t *testing.T) { | |||||
| 170 | } | 177 | } | ||
| 171 | 178 | ||||
| 172 | func TestClientAddr(t *testing.T) { | 179 | func TestClientAddr(t *testing.T) { | ||
| 180 | + | t.Parallel() | |||
| 173 | trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")} | 181 | trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")} | ||
| 174 | tests := []struct { | 182 | tests := []struct { | ||
| 175 | name string | 183 | name string | ||
| @@ -194,6 +202,7 @@ func TestClientAddr(t *testing.T) { | |||||
| 194 | } | 202 | } | ||
| 195 | for _, tt := range tests { | 203 | for _, tt := range tests { | ||
| 196 | t.Run(tt.name, func(t *testing.T) { | 204 | t.Run(tt.name, func(t *testing.T) { | ||
| 205 | + | t.Parallel() | |||
| 197 | r := &http.Request{RemoteAddr: tt.remote, Header: http.Header{}} | 206 | r := &http.Request{RemoteAddr: tt.remote, Header: http.Header{}} | ||
| 198 | for _, v := range tt.xff { | 207 | for _, v := range tt.xff { | ||
| 199 | r.Header.Add("X-Forwarded-For", v) | 208 | r.Header.Add("X-Forwarded-For", v) | ||
M internal/config/config.go +1 -1
| @@ -1,7 +1,7 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | // Package config parses and validates the server configuration from the | 3 | // Package config parses and validates the server configuration from the | ||
| 4 | - | // environment. It is an entrypoint helper: only cmd imports it, and it never | 4 | + | // environment. It is a composition-root helper: only internal/app imports it, and it never |
| 5 | // reads the process environment itself, the caller passes a lookup function. | 5 | // reads the process environment itself, the caller passes a lookup function. | ||
| 6 | package config | 6 | package config | ||
| 7 | 7 | ||||
M internal/domain/errors.go +22 -0
| @@ -37,3 +37,25 @@ var ( | |||||
| 37 | // the number of event watchers; the caller may retry later. | 37 | // the number of event watchers; the caller may retry later. | ||
| 38 | ErrResourceExhausted = errors.New("resource exhausted") | 38 | ErrResourceExhausted = errors.New("resource exhausted") | ||
| 39 | ) | 39 | ) | ||
| 40 | + | ||||
| 41 | + | // invalidErrors are the errors that mean a request was malformed. | |||
| 42 | + | var invalidErrors = []error{ | |||
| 43 | + | ErrInvalid, ErrInvalidName, ErrInvalidVisibility, | |||
| 44 | + | ErrInvalidScope, ErrInvalidToken, | |||
| 45 | + | ErrInvalidObjectID, ErrInvalidRevision, ErrInvalidPath, | |||
| 46 | + | ErrInvalidPageToken, ErrInvalidPageSize, ErrInvalidRefKind, | |||
| 47 | + | ErrNotDirectory, ErrNotFile, ErrInvalidText, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // IsInvalid reports whether err means that the request was malformed: an | |||
| 51 | + | // invalid name, revision, path, page token, text, and the like. The adapters | |||
| 52 | + | // answer all of them alike (invalid_argument on the API, the 404 page on the | |||
| 53 | + | // web). | |||
| 54 | + | func IsInvalid(err error) bool { | |||
| 55 | + | for _, invalid := range invalidErrors { | |||
| 56 | + | if errors.Is(err, invalid) { | |||
| 57 | + | return true | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return false | |||
| 61 | + | } | |||
A internal/domain/errors_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestIsInvalid(t *testing.T) { | |||
| 14 | + | t.Parallel() | |||
| 15 | + | var named *InvalidNameError | |||
| 16 | + | _, parseErr := ParseRepoName("Bad Name") | |||
| 17 | + | assert.ErrorAs(t, parseErr, &named) | |||
| 18 | + | ||||
| 19 | + | tests := []struct { | |||
| 20 | + | name string | |||
| 21 | + | err error | |||
| 22 | + | want bool | |||
| 23 | + | }{ | |||
| 24 | + | {"nil", nil, false}, | |||
| 25 | + | {"invalid", ErrInvalid, true}, | |||
| 26 | + | {"wrapped invalid", fmt.Errorf("x: %w", ErrInvalidRevision), true}, | |||
| 27 | + | {"invalid name from the parser", parseErr, true}, | |||
| 28 | + | {"invalid fields", &InvalidFieldsError{Fields: []FieldViolation{{Field: "title", Reason: "is empty"}}}, true}, | |||
| 29 | + | {"page token", ErrInvalidPageToken, true}, | |||
| 30 | + | {"not a file", ErrNotFile, true}, | |||
| 31 | + | {"invalid text", ErrInvalidText, true}, | |||
| 32 | + | {"not found is not invalid", ErrNotFound, false}, | |||
| 33 | + | {"forbidden is not invalid", ErrForbidden, false}, | |||
| 34 | + | {"other", errors.New("boom"), false}, | |||
| 35 | + | } | |||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | t.Parallel() | |||
| 39 | + | assert.Equal(t, tt.want, IsInvalid(tt.err)) | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
M internal/domain/event_test.go +2 -0
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | func TestNewRefUpdate(t *testing.T) { | 15 | func TestNewRefUpdate(t *testing.T) { | ||
| 16 | + | t.Parallel() | |||
| 16 | a, b := strings.Repeat("a", 40), strings.Repeat("b", 40) | 17 | a, b := strings.Repeat("a", 40), strings.Repeat("b", 40) | ||
| 17 | 18 | ||||
| 18 | u, err := domain.NewRefUpdate(domain.ZeroOID, a, "refs/heads/main") | 19 | u, err := domain.NewRefUpdate(domain.ZeroOID, a, "refs/heads/main") | ||
| @@ -46,6 +47,7 @@ func TestNewRefUpdate(t *testing.T) { | |||||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| 48 | func TestEventKinds(t *testing.T) { | 49 | func TestEventKinds(t *testing.T) { | ||
| 50 | + | t.Parallel() | |||
| 49 | assert.Equal(t, "repo.created", domain.RepoCreated{}.EventKind()) | 51 | assert.Equal(t, "repo.created", domain.RepoCreated{}.EventKind()) | ||
| 50 | assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind()) | 52 | assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind()) | ||
| 51 | assert.Equal(t, "gap", domain.EventGap{}.EventKind()) | 53 | assert.Equal(t, "gap", domain.EventGap{}.EventKind()) | ||
M internal/domain/git_test.go +6 -0
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | func TestParseObjectID(t *testing.T) { | 15 | func TestParseObjectID(t *testing.T) { | ||
| 16 | + | t.Parallel() | |||
| 16 | good := strings.Repeat("ab12", 10) | 17 | good := strings.Repeat("ab12", 10) | ||
| 17 | id, err := domain.ParseObjectID(good) | 18 | id, err := domain.ParseObjectID(good) | ||
| 18 | require.NoError(t, err) | 19 | require.NoError(t, err) | ||
| @@ -27,6 +28,7 @@ func TestParseObjectID(t *testing.T) { | |||||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | func TestParseRevision(t *testing.T) { | 30 | func TestParseRevision(t *testing.T) { | ||
| 31 | + | t.Parallel() | |||
| 30 | valid := []string{ | 32 | valid := []string{ | ||
| 31 | "", "main", "feature/x", "refs/heads/feature/x", "v1.0.0", "HEAD", | 33 | "", "main", "feature/x", "refs/heads/feature/x", "v1.0.0", "HEAD", | ||
| 32 | "abc1234", strings.Repeat("a", 40), "release-2026.10", "café", | 34 | "abc1234", strings.Repeat("a", 40), "release-2026.10", "café", | ||
| @@ -47,6 +49,7 @@ func TestParseRevision(t *testing.T) { | |||||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 49 | func TestRevisionKinds(t *testing.T) { | 51 | func TestRevisionKinds(t *testing.T) { | ||
| 52 | + | t.Parallel() | |||
| 50 | head, _ := domain.ParseRevision("") | 53 | head, _ := domain.ParseRevision("") | ||
| 51 | assert.True(t, head.IsHead()) | 54 | assert.True(t, head.IsHead()) | ||
| 52 | full, _ := domain.ParseRevision(strings.Repeat("a", 40)) | 55 | full, _ := domain.ParseRevision(strings.Repeat("a", 40)) | ||
| @@ -64,6 +67,7 @@ func TestRevisionKinds(t *testing.T) { | |||||
| 64 | } | 67 | } | ||
| 65 | 68 | ||||
| 66 | func TestParseTreePath(t *testing.T) { | 69 | func TestParseTreePath(t *testing.T) { | ||
| 70 | + | t.Parallel() | |||
| 67 | root, err := domain.ParseTreePath("") | 71 | root, err := domain.ParseTreePath("") | ||
| 68 | require.NoError(t, err) | 72 | require.NoError(t, err) | ||
| 69 | assert.True(t, root.IsRoot()) | 73 | assert.True(t, root.IsRoot()) | ||
| @@ -150,6 +154,7 @@ func TestPathListAdd(t *testing.T) { | |||||
| 150 | } | 154 | } | ||
| 151 | 155 | ||||
| 152 | func TestEntryKindOf(t *testing.T) { | 156 | func TestEntryKindOf(t *testing.T) { | ||
| 157 | + | t.Parallel() | |||
| 153 | tests := map[uint32]domain.EntryKind{ | 158 | tests := map[uint32]domain.EntryKind{ | ||
| 154 | 0o100644: domain.EntryFile, | 159 | 0o100644: domain.EntryFile, | ||
| 155 | 0o100664: domain.EntryFile, | 160 | 0o100664: domain.EntryFile, | ||
| @@ -166,5 +171,6 @@ func TestEntryKindOf(t *testing.T) { | |||||
| 166 | } | 171 | } | ||
| 167 | 172 | ||||
| 168 | func TestDefaultDiffLimits(t *testing.T) { | 173 | func TestDefaultDiffLimits(t *testing.T) { | ||
| 174 | + | t.Parallel() | |||
| 169 | assert.Equal(t, domain.DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}, domain.DefaultDiffLimits()) | 175 | assert.Equal(t, domain.DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}, domain.DefaultDiffLimits()) | ||
| 170 | } | 176 | } | ||
M internal/domain/mergerequest_test.go +10 -0
| @@ -21,6 +21,7 @@ func oid(t *testing.T, c byte) domain.ObjectID { | |||||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | func TestDefaultRepoSettings(t *testing.T) { | 23 | func TestDefaultRepoSettings(t *testing.T) { | ||
| 24 | + | t.Parallel() | |||
| 24 | s := domain.DefaultRepoSettings() | 25 | s := domain.DefaultRepoSettings() | ||
| 25 | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes) | 26 | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes) | ||
| 26 | assert.True(t, s.FailingChecksBlock) | 27 | assert.True(t, s.FailingChecksBlock) | ||
| @@ -35,6 +36,7 @@ func TestDefaultRepoSettings(t *testing.T) { | |||||
| 35 | } | 36 | } | ||
| 36 | 37 | ||||
| 37 | func TestNormalizeMergeModes(t *testing.T) { | 38 | func TestNormalizeMergeModes(t *testing.T) { | ||
| 39 | + | t.Parallel() | |||
| 38 | got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash}) | 40 | got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash}) | ||
| 39 | require.NoError(t, err) | 41 | require.NoError(t, err) | ||
| 40 | assert.Equal(t, domain.MergeModes(), got) | 42 | assert.Equal(t, domain.MergeModes(), got) | ||
| @@ -50,6 +52,7 @@ func TestNormalizeMergeModes(t *testing.T) { | |||||
| 50 | } | 52 | } | ||
| 51 | 53 | ||||
| 52 | func TestParseMergeMode(t *testing.T) { | 54 | func TestParseMergeMode(t *testing.T) { | ||
| 55 | + | t.Parallel() | |||
| 53 | m, err := domain.ParseMergeMode("fast-forward") | 56 | m, err := domain.ParseMergeMode("fast-forward") | ||
| 54 | require.NoError(t, err) | 57 | require.NoError(t, err) | ||
| 55 | assert.Equal(t, domain.MergeModeFastForward, m) | 58 | assert.Equal(t, domain.MergeModeFastForward, m) | ||
| @@ -78,6 +81,7 @@ func hunkDiff() domain.Diff { | |||||
| 78 | } | 81 | } | ||
| 79 | 82 | ||||
| 80 | func TestDiffHasLine(t *testing.T) { | 83 | func TestDiffHasLine(t *testing.T) { | ||
| 84 | + | t.Parallel() | |||
| 81 | d := hunkDiff() | 85 | d := hunkDiff() | ||
| 82 | tests := []struct { | 86 | tests := []struct { | ||
| 83 | name string | 87 | name string | ||
| @@ -106,6 +110,7 @@ func TestDiffHasLine(t *testing.T) { | |||||
| 106 | } | 110 | } | ||
| 107 | for _, tc := range tests { | 111 | for _, tc := range tests { | ||
| 108 | t.Run(tc.name, func(t *testing.T) { | 112 | t.Run(tc.name, func(t *testing.T) { | ||
| 113 | + | t.Parallel() | |||
| 109 | assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line)) | 114 | assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line)) | ||
| 110 | }) | 115 | }) | ||
| 111 | } | 116 | } | ||
| @@ -116,6 +121,7 @@ func sig(name, email string, sec int64) domain.Signature { | |||||
| 116 | } | 121 | } | ||
| 117 | 122 | ||||
| 118 | func TestNewSquashCommit(t *testing.T) { | 123 | func TestNewSquashCommit(t *testing.T) { | ||
| 124 | + | t.Parallel() | |||
| 119 | title, err := domain.ParseMRTitle("Add search") | 125 | title, err := domain.ParseMRTitle("Add search") | ||
| 120 | require.NoError(t, err) | 126 | require.NoError(t, err) | ||
| 121 | mr := domain.MergeRequest{Number: 7, Title: title} | 127 | mr := domain.MergeRequest{Number: 7, Title: title} | ||
| @@ -151,6 +157,7 @@ func TestNewSquashCommit(t *testing.T) { | |||||
| 151 | } | 157 | } | ||
| 152 | 158 | ||||
| 153 | func TestSquashCommitSigned(t *testing.T) { | 159 | func TestSquashCommitSigned(t *testing.T) { | ||
| 160 | + | t.Parallel() | |||
| 154 | title, err := domain.ParseMRTitle("T") | 161 | title, err := domain.ParseMRTitle("T") | ||
| 155 | require.NoError(t, err) | 162 | require.NoError(t, err) | ||
| 156 | c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'), | 163 | c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'), | ||
| @@ -169,6 +176,7 @@ func TestSquashCommitSigned(t *testing.T) { | |||||
| 169 | } | 176 | } | ||
| 170 | 177 | ||||
| 171 | func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) { | 178 | func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) { | ||
| 179 | + | t.Parallel() | |||
| 172 | title, err := domain.ParseMRTitle("T") | 180 | title, err := domain.ParseMRTitle("T") | ||
| 173 | require.NoError(t, err) | 181 | require.NoError(t, err) | ||
| 174 | commits := []domain.Commit{ | 182 | commits := []domain.Commit{ | ||
| @@ -183,6 +191,7 @@ func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) { | |||||
| 183 | } | 191 | } | ||
| 184 | 192 | ||||
| 185 | func TestSigningKeyInfoAllowedSigners(t *testing.T) { | 193 | func TestSigningKeyInfoAllowedSigners(t *testing.T) { | ||
| 194 | + | t.Parallel() | |||
| 186 | i := domain.SigningKeyInfo{ | 195 | i := domain.SigningKeyInfo{ | ||
| 187 | Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"}, | 196 | Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"}, | ||
| 188 | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | 197 | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | ||
| @@ -191,6 +200,7 @@ func TestSigningKeyInfoAllowedSigners(t *testing.T) { | |||||
| 191 | } | 200 | } | ||
| 192 | 201 | ||||
| 193 | func TestParseBranchName(t *testing.T) { | 202 | func TestParseBranchName(t *testing.T) { | ||
| 203 | + | t.Parallel() | |||
| 194 | for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} { | 204 | for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} { | ||
| 195 | got, err := domain.ParseBranchName(ok) | 205 | got, err := domain.ParseBranchName(ok) | ||
| 196 | require.NoError(t, err, ok) | 206 | require.NoError(t, err, ok) | ||
M internal/domain/name_test.go +6 -0
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | func TestParseRepoName(t *testing.T) { | 16 | func TestParseRepoName(t *testing.T) { | ||
| 17 | + | t.Parallel() | |||
| 17 | tests := []struct { | 18 | tests := []struct { | ||
| 18 | in string | 19 | in string | ||
| 19 | valid bool | 20 | valid bool | ||
| @@ -45,6 +46,7 @@ func TestParseRepoName(t *testing.T) { | |||||
| 45 | } | 46 | } | ||
| 46 | for _, tt := range tests { | 47 | for _, tt := range tests { | ||
| 47 | t.Run(tt.in, func(t *testing.T) { | 48 | t.Run(tt.in, func(t *testing.T) { | ||
| 49 | + | t.Parallel() | |||
| 48 | n, err := domain.ParseRepoName(tt.in) | 50 | n, err := domain.ParseRepoName(tt.in) | ||
| 49 | if tt.valid { | 51 | if tt.valid { | ||
| 50 | require.NoError(t, err) | 52 | require.NoError(t, err) | ||
| @@ -66,6 +68,7 @@ func TestParseRepoName(t *testing.T) { | |||||
| 66 | } | 68 | } | ||
| 67 | 69 | ||||
| 68 | func TestParseRepoPath(t *testing.T) { | 70 | func TestParseRepoPath(t *testing.T) { | ||
| 71 | + | t.Parallel() | |||
| 69 | tests := []struct { | 72 | tests := []struct { | ||
| 70 | in string | 73 | in string | ||
| 71 | want string | 74 | want string | ||
| @@ -80,6 +83,7 @@ func TestParseRepoPath(t *testing.T) { | |||||
| 80 | } | 83 | } | ||
| 81 | for _, tt := range tests { | 84 | for _, tt := range tests { | ||
| 82 | t.Run(tt.in, func(t *testing.T) { | 85 | t.Run(tt.in, func(t *testing.T) { | ||
| 86 | + | t.Parallel() | |||
| 83 | n, err := domain.ParseRepoPath(tt.in) | 87 | n, err := domain.ParseRepoPath(tt.in) | ||
| 84 | if !tt.valid { | 88 | if !tt.valid { | ||
| 85 | assert.ErrorIs(t, err, domain.ErrInvalidName) | 89 | assert.ErrorIs(t, err, domain.ErrInvalidName) | ||
| @@ -92,6 +96,7 @@ func TestParseRepoPath(t *testing.T) { | |||||
| 92 | } | 96 | } | ||
| 93 | 97 | ||||
| 94 | func TestParseVisibility(t *testing.T) { | 98 | func TestParseVisibility(t *testing.T) { | ||
| 99 | + | t.Parallel() | |||
| 95 | for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} { | 100 | for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} { | ||
| 96 | got, err := domain.ParseVisibility(in) | 101 | got, err := domain.ParseVisibility(in) | ||
| 97 | require.NoError(t, err) | 102 | require.NoError(t, err) | ||
| @@ -104,6 +109,7 @@ func TestParseVisibility(t *testing.T) { | |||||
| 104 | } | 109 | } | ||
| 105 | 110 | ||||
| 106 | func TestPrincipal(t *testing.T) { | 111 | func TestPrincipal(t *testing.T) { | ||
| 112 | + | t.Parallel() | |||
| 107 | var zero domain.Principal | 113 | var zero domain.Principal | ||
| 108 | assert.True(t, zero.IsAnonymous()) | 114 | assert.True(t, zero.IsAnonymous()) | ||
| 109 | assert.False(t, zero.IsRoot()) | 115 | assert.False(t, zero.IsRoot()) | ||
M internal/domain/text_test.go +6 -0
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | // textCases are the rules shared by every text; multi says whether line breaks | 16 | // textCases are the rules shared by every text; multi says whether line breaks | ||
| 17 | // are allowed. | 17 | // are allowed. | ||
| 18 | func TestParseMRTitle(t *testing.T) { | 18 | func TestParseMRTitle(t *testing.T) { | ||
| 19 | + | t.Parallel() | |||
| 19 | tests := []struct { | 20 | tests := []struct { | ||
| 20 | name string | 21 | name string | ||
| 21 | in string | 22 | in string | ||
| @@ -48,6 +49,7 @@ func TestParseMRTitle(t *testing.T) { | |||||
| 48 | } | 49 | } | ||
| 49 | for _, tc := range tests { | 50 | for _, tc := range tests { | ||
| 50 | t.Run(tc.name, func(t *testing.T) { | 51 | t.Run(tc.name, func(t *testing.T) { | ||
| 52 | + | t.Parallel() | |||
| 51 | got, err := domain.ParseMRTitle(tc.in) | 53 | got, err := domain.ParseMRTitle(tc.in) | ||
| 52 | if tc.reason == "" { | 54 | if tc.reason == "" { | ||
| 53 | require.NoError(t, err) | 55 | require.NoError(t, err) | ||
| @@ -64,6 +66,7 @@ func TestParseMRTitle(t *testing.T) { | |||||
| 64 | } | 66 | } | ||
| 65 | 67 | ||||
| 66 | func TestParsePlainText(t *testing.T) { | 68 | func TestParsePlainText(t *testing.T) { | ||
| 69 | + | t.Parallel() | |||
| 67 | tests := []struct { | 70 | tests := []struct { | ||
| 68 | name string | 71 | name string | ||
| 69 | in string | 72 | in string | ||
| @@ -92,6 +95,7 @@ func TestParsePlainText(t *testing.T) { | |||||
| 92 | } | 95 | } | ||
| 93 | for _, tc := range tests { | 96 | for _, tc := range tests { | ||
| 94 | t.Run(tc.name, func(t *testing.T) { | 97 | t.Run(tc.name, func(t *testing.T) { | ||
| 98 | + | t.Parallel() | |||
| 95 | got, err := domain.ParsePlainText(tc.in) | 99 | got, err := domain.ParsePlainText(tc.in) | ||
| 96 | if tc.reason == "" { | 100 | if tc.reason == "" { | ||
| 97 | require.NoError(t, err) | 101 | require.NoError(t, err) | ||
| @@ -107,6 +111,7 @@ func TestParsePlainText(t *testing.T) { | |||||
| 107 | } | 111 | } | ||
| 108 | 112 | ||||
| 109 | func TestPlainTextIsBlank(t *testing.T) { | 113 | func TestPlainTextIsBlank(t *testing.T) { | ||
| 114 | + | t.Parallel() | |||
| 110 | for in, blank := range map[string]bool{"": true, " \n\n ": true, "x": false, " x ": false} { | 115 | for in, blank := range map[string]bool{"": true, " \n\n ": true, "x": false, " x ": false} { | ||
| 111 | got, err := domain.ParsePlainText(in) | 116 | got, err := domain.ParsePlainText(in) | ||
| 112 | require.NoError(t, err) | 117 | require.NoError(t, err) | ||
| @@ -115,6 +120,7 @@ func TestPlainTextIsBlank(t *testing.T) { | |||||
| 115 | } | 120 | } | ||
| 116 | 121 | ||||
| 117 | func TestViolations(t *testing.T) { | 122 | func TestViolations(t *testing.T) { | ||
| 123 | + | t.Parallel() | |||
| 118 | var v domain.Violations | 124 | var v domain.Violations | ||
| 119 | require.NoError(t, v.Err()) | 125 | require.NoError(t, v.Err()) | ||
| 120 | 126 | ||||
M internal/domain/token_test.go +6 -0
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | func TestScopes(t *testing.T) { | 15 | func TestScopes(t *testing.T) { | ||
| 16 | + | t.Parallel() | |||
| 16 | all := domain.AllScopes() | 17 | all := domain.AllScopes() | ||
| 17 | assert.Len(t, all, 9) | 18 | assert.Len(t, all, 9) | ||
| 18 | for _, s := range all { | 19 | for _, s := range all { | ||
| @@ -27,6 +28,7 @@ func TestScopes(t *testing.T) { | |||||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | func TestHashTokenSecret(t *testing.T) { | 30 | func TestHashTokenSecret(t *testing.T) { | ||
| 31 | + | t.Parallel() | |||
| 30 | h := domain.HashTokenSecret("ermite_abc") | 32 | h := domain.HashTokenSecret("ermite_abc") | ||
| 31 | assert.Equal(t, h, domain.HashTokenSecret("ermite_abc")) | 33 | assert.Equal(t, h, domain.HashTokenSecret("ermite_abc")) | ||
| 32 | assert.NotEqual(t, h, domain.HashTokenSecret("ermite_abd")) | 34 | assert.NotEqual(t, h, domain.HashTokenSecret("ermite_abd")) | ||
| @@ -36,6 +38,7 @@ func TestHashTokenSecret(t *testing.T) { | |||||
| 36 | } | 38 | } | ||
| 37 | 39 | ||||
| 38 | func TestTokenExpired(t *testing.T) { | 40 | func TestTokenExpired(t *testing.T) { | ||
| 41 | + | t.Parallel() | |||
| 39 | now := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) | 42 | now := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) | ||
| 40 | assert.False(t, domain.Token{}.Expired(now), "no expiry never expires") | 43 | assert.False(t, domain.Token{}.Expired(now), "no expiry never expires") | ||
| 41 | assert.False(t, domain.Token{ExpiresAt: now.Add(time.Second)}.Expired(now)) | 44 | assert.False(t, domain.Token{ExpiresAt: now.Add(time.Second)}.Expired(now)) | ||
| @@ -44,6 +47,7 @@ func TestTokenExpired(t *testing.T) { | |||||
| 44 | } | 47 | } | ||
| 45 | 48 | ||||
| 46 | func TestPrincipalScopes(t *testing.T) { | 49 | func TestPrincipalScopes(t *testing.T) { | ||
| 50 | + | t.Parallel() | |||
| 47 | a, b := mustName(t, "a"), mustName(t, "b") | 51 | a, b := mustName(t, "a"), mustName(t, "b") | ||
| 48 | tok := domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, Repos: []domain.RepoName{a}} | 52 | tok := domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, Repos: []domain.RepoName{a}} | ||
| 49 | p := domain.TokenPrincipal(tok) | 53 | p := domain.TokenPrincipal(tok) | ||
| @@ -84,6 +88,7 @@ func mustName(t *testing.T, s string) domain.RepoName { | |||||
| 84 | } | 88 | } | ||
| 85 | 89 | ||||
| 86 | func TestOwnerPrincipal(t *testing.T) { | 90 | func TestOwnerPrincipal(t *testing.T) { | ||
| 91 | + | t.Parallel() | |||
| 87 | o := domain.Owner() | 92 | o := domain.Owner() | ||
| 88 | assert.Equal(t, "owner", o.Label()) | 93 | assert.Equal(t, "owner", o.Label()) | ||
| 89 | assert.True(t, o.IsOwner()) | 94 | assert.True(t, o.IsOwner()) | ||
| @@ -96,6 +101,7 @@ func TestOwnerPrincipal(t *testing.T) { | |||||
| 96 | } | 101 | } | ||
| 97 | 102 | ||||
| 98 | func TestHasOwnerRights(t *testing.T) { | 103 | func TestHasOwnerRights(t *testing.T) { | ||
| 104 | + | t.Parallel() | |||
| 99 | a := mustName(t, "a") | 105 | a := mustName(t, "a") | ||
| 100 | all := domain.AllScopes() | 106 | all := domain.AllScopes() | ||
| 101 | assert.True(t, domain.Root().HasOwnerRights()) | 107 | assert.True(t, domain.Root().HasOwnerRights()) | ||
A internal/ports/dto.go +228 -0
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ports | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The parameters and results of the use case interfaces (services.go). They | |||
| 12 | + | // live here so that the adapters that call the use cases and the use cases | |||
| 13 | + | // themselves share them without importing each other. | |||
| 14 | + | ||||
| 15 | + | // RepoPage is a page of repositories ordered by name. | |||
| 16 | + | type RepoPage struct { | |||
| 17 | + | Repos []domain.Repo | |||
| 18 | + | // NextPageToken is empty on the last page. | |||
| 19 | + | NextPageToken string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // RepoView is a repository with the Git state that belongs in its summary. | |||
| 23 | + | type RepoView struct { | |||
| 24 | + | domain.Repo | |||
| 25 | + | // DefaultBranch is the branch HEAD points to, even when it does not exist | |||
| 26 | + | // yet; empty when HEAD is detached or the directory is missing. | |||
| 27 | + | DefaultBranch string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // CreateRepoParams are the settings of a new repository. | |||
| 31 | + | type CreateRepoParams struct { | |||
| 32 | + | Name domain.RepoName | |||
| 33 | + | Description string | |||
| 34 | + | // Visibility defaults to private when empty. | |||
| 35 | + | Visibility domain.Visibility | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // UpdateRepoParams are the changes of Update; a nil field is left alone. | |||
| 39 | + | type UpdateRepoParams struct { | |||
| 40 | + | Description *string | |||
| 41 | + | Visibility *domain.Visibility | |||
| 42 | + | DefaultBranch *string | |||
| 43 | + | // AllowedMergeModes replaces the allowed merge modes; it must hold at | |||
| 44 | + | // least one known mode. | |||
| 45 | + | AllowedMergeModes *[]domain.MergeMode | |||
| 46 | + | // FailingChecksBlock replaces the setting of that name. | |||
| 47 | + | FailingChecksBlock *bool | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // TokenPage is a page of stored tokens, newest first. | |||
| 51 | + | type TokenPage struct { | |||
| 52 | + | Tokens []domain.Token | |||
| 53 | + | // NextPageToken is empty on the last page. | |||
| 54 | + | NextPageToken string | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // CreateTokenParams are the settings of a new token. | |||
| 58 | + | type CreateTokenParams struct { | |||
| 59 | + | Label string | |||
| 60 | + | // Scopes must not be empty. | |||
| 61 | + | Scopes []domain.Scope | |||
| 62 | + | // Repos limits the token to these repositories; empty means every one. | |||
| 63 | + | Repos []domain.RepoName | |||
| 64 | + | // ExpiresAt is the zero time for a token that never expires. | |||
| 65 | + | ExpiresAt time.Time | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // CreatedToken is a new token with its secret, which is shown once and never | |||
| 69 | + | // stored. | |||
| 70 | + | type CreatedToken struct { | |||
| 71 | + | Token domain.Token | |||
| 72 | + | Secret string | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // ListRefsParams selects the refs to list. | |||
| 76 | + | type ListRefsParams struct { | |||
| 77 | + | // Kinds restricts the result; empty means every kind. | |||
| 78 | + | Kinds []domain.RefKind | |||
| 79 | + | // PageSize is 0 for the default, and lowered to MaxRefPageSize. | |||
| 80 | + | PageSize int | |||
| 81 | + | // PageToken is the NextPageToken of the previous page, or empty. | |||
| 82 | + | PageToken string | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // RefPage is a page of refs. | |||
| 86 | + | type RefPage struct { | |||
| 87 | + | Refs []domain.Ref | |||
| 88 | + | // NextPageToken is empty on the last page. | |||
| 89 | + | NextPageToken string | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // ListCommitsParams selects a page of history. | |||
| 93 | + | type ListCommitsParams struct { | |||
| 94 | + | // Revision is where the history starts; the zero value is HEAD. Ignored | |||
| 95 | + | // when PageToken is set: the token pins the commit the first page started | |||
| 96 | + | // from, so every page walks the same history even if a branch moves. | |||
| 97 | + | Revision domain.Revision | |||
| 98 | + | // Path limits the history to the commits that changed it. It must be the | |||
| 99 | + | // same on every page of a listing. | |||
| 100 | + | Path domain.TreePath | |||
| 101 | + | // PageSize is 0 for the default, and lowered to MaxPageSize. | |||
| 102 | + | PageSize int | |||
| 103 | + | // PageToken is the NextPageToken of the previous page, or empty. | |||
| 104 | + | PageToken string | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // HistoryPage is a page of history, newest first. | |||
| 108 | + | type HistoryPage struct { | |||
| 109 | + | Commits []domain.Commit | |||
| 110 | + | // NextPageToken is empty on the last page. | |||
| 111 | + | NextPageToken string | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // WatchParams select the events of a watch. | |||
| 115 | + | type WatchParams struct { | |||
| 116 | + | // Topics are TopicHome or "repo:<name>"; empty means TopicHome. | |||
| 117 | + | Topics []string | |||
| 118 | + | // ResumeAfter is the id of the last event the caller saw; empty to | |||
| 119 | + | // receive only new events. | |||
| 120 | + | ResumeAfter string | |||
| 121 | + | // Client identifies where the watch comes from, for the limit per client | |||
| 122 | + | // (MaxWatchersPerClient): an opaque key such as a normalized address; the | |||
| 123 | + | // web passes its visitor's. Empty only for callers with no client, which | |||
| 124 | + | // count against MaxWatchers alone. | |||
| 125 | + | Client string | |||
| 126 | + | // Ready, when not nil, is called once the topics are valid and the watch | |||
| 127 | + | // is registered: events published from then on are not missed. Nothing is | |||
| 128 | + | // sent before it returns. | |||
| 129 | + | Ready func() | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // WatchEvent is one item of a watch. | |||
| 133 | + | type WatchEvent struct { | |||
| 134 | + | // ID identifies the event, for resuming. Empty on a gap. | |||
| 135 | + | ID string | |||
| 136 | + | // Gap tells that events were lost: the caller must reload what it shows. | |||
| 137 | + | // Event is nil then. | |||
| 138 | + | Gap bool | |||
| 139 | + | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent, | |||
| 140 | + | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or | |||
| 141 | + | // CommentAdded. | |||
| 142 | + | Event domain.Event | |||
| 143 | + | // Repo is the state of the repository at delivery, for RepoCreated and | |||
| 144 | + | // RepoUpdated. | |||
| 145 | + | Repo *RepoView | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // CreateMergeRequestParams describes the merge request to open. | |||
| 149 | + | type CreateMergeRequestParams struct { | |||
| 150 | + | Title string | |||
| 151 | + | Description string | |||
| 152 | + | // Source is required; Target defaults to the default branch. | |||
| 153 | + | Source, Target string | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // ListMergeRequestsParams selects a page of merge requests. | |||
| 157 | + | type ListMergeRequestsParams struct { | |||
| 158 | + | // State restricts the result; empty means every state. | |||
| 159 | + | State domain.MRState | |||
| 160 | + | // PageSize is 0 for the default, and lowered to MaxPageSize. | |||
| 161 | + | PageSize int | |||
| 162 | + | // PageToken is the NextPageToken of the previous page, or empty. | |||
| 163 | + | PageToken string | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // MergeRequestPage is a page of merge requests, highest number first. | |||
| 167 | + | type MergeRequestPage struct { | |||
| 168 | + | MergeRequests []domain.MergeRequest | |||
| 169 | + | // NextPageToken is empty on the last page. | |||
| 170 | + | NextPageToken string | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // MergeRequestDetail is a merge request with its review data. | |||
| 174 | + | type MergeRequestDetail struct { | |||
| 175 | + | MergeRequest domain.MergeRequest | |||
| 176 | + | // Commits are the commits of the head that the target lacks, newest | |||
| 177 | + | // first, at most MaxMergeRequestCommits. | |||
| 178 | + | Commits []domain.Commit | |||
| 179 | + | CommitsTruncated bool | |||
| 180 | + | // DiffBaseID is the commit the diff compares from; zero when the source | |||
| 181 | + | // branch is gone, then the diff is empty. | |||
| 182 | + | DiffBaseID domain.ObjectID | |||
| 183 | + | Stats domain.DiffStats | |||
| 184 | + | Diff domain.Diff | |||
| 185 | + | // Comments are the comments, oldest first. | |||
| 186 | + | Comments []domain.Comment | |||
| 187 | + | // LastReadID is the head recorded by MarkRead; zero when none. | |||
| 188 | + | LastReadID domain.ObjectID | |||
| 189 | + | // LastReadLost tells that since-last-read was asked for, a head was | |||
| 190 | + | // recorded, but that commit is gone: the diff is against the target. | |||
| 191 | + | LastReadLost bool | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // AddCommentParams describes the comment to add. | |||
| 195 | + | type AddCommentParams struct { | |||
| 196 | + | // Path, Line and Side designate a line of the diff; all empty for a | |||
| 197 | + | // general comment. | |||
| 198 | + | Path string | |||
| 199 | + | Line int | |||
| 200 | + | Side domain.DiffSide | |||
| 201 | + | Text string | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // MergeParams describes the merge to perform. | |||
| 205 | + | type MergeParams struct { | |||
| 206 | + | // Mode defaults to fast-forward. | |||
| 207 | + | Mode domain.MergeMode | |||
| 208 | + | // HeadID is the commit the caller reviewed; the source branch must still | |||
| 209 | + | // point to it. | |||
| 210 | + | HeadID domain.ObjectID | |||
| 211 | + | // DeleteSource deletes the source branch after the merge. | |||
| 212 | + | DeleteSource bool | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // MergeResult is a merged request and what happened to its source branch. | |||
| 216 | + | type MergeResult struct { | |||
| 217 | + | domain.MergeRequest | |||
| 218 | + | // SourceDeleted is true when DeleteSource was asked and the branch is | |||
| 219 | + | // gone. | |||
| 220 | + | SourceDeleted bool | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // LoginLink is a login link as the owner receives it. | |||
| 224 | + | type LoginLink struct { | |||
| 225 | + | // URL holds the secret: show it once, never log it. | |||
| 226 | + | URL string | |||
| 227 | + | ExpiresAt time.Time | |||
| 228 | + | } | |||
A internal/ports/mocks/AuthService.go +114 -0
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewAuthService creates a new instance of AuthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewAuthService(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *AuthService { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &AuthService{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // AuthService is an autogenerated mock type for the AuthService type | |||
| 38 | + | type AuthService struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type AuthService_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *AuthService) EXPECT() *AuthService_Expecter { | |||
| 47 | + | return &AuthService_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Authenticate provides a mock function for the type AuthService | |||
| 51 | + | func (_mock *AuthService) Authenticate(ctx context.Context, secret string) (domain.Principal, error) { | |||
| 52 | + | ret := _mock.Called(ctx, secret) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Authenticate") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 domain.Principal | |||
| 59 | + | var r1 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, error)); ok { | |||
| 61 | + | return returnFunc(ctx, secret) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok { | |||
| 64 | + | r0 = returnFunc(ctx, secret) | |||
| 65 | + | } else { | |||
| 66 | + | r0 = ret.Get(0).(domain.Principal) | |||
| 67 | + | } | |||
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 69 | + | r1 = returnFunc(ctx, secret) | |||
| 70 | + | } else { | |||
| 71 | + | r1 = ret.Error(1) | |||
| 72 | + | } | |||
| 73 | + | return r0, r1 | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // AuthService_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate' | |||
| 77 | + | type AuthService_Authenticate_Call struct { | |||
| 78 | + | *mock.Call | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Authenticate is a helper method to define mock.On call | |||
| 82 | + | // - ctx context.Context | |||
| 83 | + | // - secret string | |||
| 84 | + | func (_e *AuthService_Expecter) Authenticate(ctx any, secret any) *AuthService_Authenticate_Call { | |||
| 85 | + | return &AuthService_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, secret)} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (_c *AuthService_Authenticate_Call) Run(run func(ctx context.Context, secret string)) *AuthService_Authenticate_Call { | |||
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 90 | + | var arg0 context.Context | |||
| 91 | + | if args[0] != nil { | |||
| 92 | + | arg0 = args[0].(context.Context) | |||
| 93 | + | } | |||
| 94 | + | var arg1 string | |||
| 95 | + | if args[1] != nil { | |||
| 96 | + | arg1 = args[1].(string) | |||
| 97 | + | } | |||
| 98 | + | run( | |||
| 99 | + | arg0, | |||
| 100 | + | arg1, | |||
| 101 | + | ) | |||
| 102 | + | }) | |||
| 103 | + | return _c | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (_c *AuthService_Authenticate_Call) Return(principal domain.Principal, err error) *AuthService_Authenticate_Call { | |||
| 107 | + | _c.Call.Return(principal, err) | |||
| 108 | + | return _c | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (_c *AuthService_Authenticate_Call) RunAndReturn(run func(ctx context.Context, secret string) (domain.Principal, error)) *AuthService_Authenticate_Call { | |||
| 112 | + | _c.Call.Return(run) | |||
| 113 | + | return _c | |||
| 114 | + | } | |||
A internal/ports/mocks/BrowseService.go +622 -0
| @@ -0,0 +1,622 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "io" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | "bnema.dev/ermite/internal/ports" | |||
| 13 | + | mock "github.com/stretchr/testify/mock" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // NewBrowseService creates a new instance of BrowseService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 17 | + | // The first argument is typically a *testing.T value. | |||
| 18 | + | func NewBrowseService(t interface { | |||
| 19 | + | mock.TestingT | |||
| 20 | + | Cleanup(func()) | |||
| 21 | + | }) *BrowseService { | |||
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 23 | + | helper.Helper() | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | mock := &BrowseService{} | |||
| 27 | + | mock.Mock.Test(t) | |||
| 28 | + | ||||
| 29 | + | t.Cleanup(func() { | |||
| 30 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 31 | + | helper.Helper() | |||
| 32 | + | } | |||
| 33 | + | mock.AssertExpectations(t) | |||
| 34 | + | }) | |||
| 35 | + | ||||
| 36 | + | return mock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // BrowseService is an autogenerated mock type for the BrowseService type | |||
| 40 | + | type BrowseService struct { | |||
| 41 | + | mock.Mock | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type BrowseService_Expecter struct { | |||
| 45 | + | mock *mock.Mock | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (_m *BrowseService) EXPECT() *BrowseService_Expecter { | |||
| 49 | + | return &BrowseService_Expecter{mock: &_m.Mock} | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // GetCommit provides a mock function for the type BrowseService | |||
| 53 | + | func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) { | |||
| 54 | + | ret := _mock.Called(ctx, p, repo, rev) | |||
| 55 | + | ||||
| 56 | + | if len(ret) == 0 { | |||
| 57 | + | panic("no return value specified for GetCommit") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | var r0 domain.CommitDetail | |||
| 61 | + | var r1 error | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok { | |||
| 63 | + | return returnFunc(ctx, p, repo, rev) | |||
| 64 | + | } | |||
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok { | |||
| 66 | + | r0 = returnFunc(ctx, p, repo, rev) | |||
| 67 | + | } else { | |||
| 68 | + | r0 = ret.Get(0).(domain.CommitDetail) | |||
| 69 | + | } | |||
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok { | |||
| 71 | + | r1 = returnFunc(ctx, p, repo, rev) | |||
| 72 | + | } else { | |||
| 73 | + | r1 = ret.Error(1) | |||
| 74 | + | } | |||
| 75 | + | return r0, r1 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // BrowseService_GetCommit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommit' | |||
| 79 | + | type BrowseService_GetCommit_Call struct { | |||
| 80 | + | *mock.Call | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // GetCommit is a helper method to define mock.On call | |||
| 84 | + | // - ctx context.Context | |||
| 85 | + | // - p domain.Principal | |||
| 86 | + | // - repo domain.RepoName | |||
| 87 | + | // - rev domain.Revision | |||
| 88 | + | func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call { | |||
| 89 | + | return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)} | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call { | |||
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 94 | + | var arg0 context.Context | |||
| 95 | + | if args[0] != nil { | |||
| 96 | + | arg0 = args[0].(context.Context) | |||
| 97 | + | } | |||
| 98 | + | var arg1 domain.Principal | |||
| 99 | + | if args[1] != nil { | |||
| 100 | + | arg1 = args[1].(domain.Principal) | |||
| 101 | + | } | |||
| 102 | + | var arg2 domain.RepoName | |||
| 103 | + | if args[2] != nil { | |||
| 104 | + | arg2 = args[2].(domain.RepoName) | |||
| 105 | + | } | |||
| 106 | + | var arg3 domain.Revision | |||
| 107 | + | if args[3] != nil { | |||
| 108 | + | arg3 = args[3].(domain.Revision) | |||
| 109 | + | } | |||
| 110 | + | run( | |||
| 111 | + | arg0, | |||
| 112 | + | arg1, | |||
| 113 | + | arg2, | |||
| 114 | + | arg3, | |||
| 115 | + | ) | |||
| 116 | + | }) | |||
| 117 | + | return _c | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail, err error) *BrowseService_GetCommit_Call { | |||
| 121 | + | _c.Call.Return(commitDetail, err) | |||
| 122 | + | return _c | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call { | |||
| 126 | + | _c.Call.Return(run) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // GetTree provides a mock function for the type BrowseService | |||
| 131 | + | func (_mock *BrowseService) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | |||
| 132 | + | ret := _mock.Called(ctx, p, repo, rev, path, includeLastCommit) | |||
| 133 | + | ||||
| 134 | + | if len(ret) == 0 { | |||
| 135 | + | panic("no return value specified for GetTree") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | var r0 domain.Tree | |||
| 139 | + | var r1 error | |||
| 140 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath, bool) (domain.Tree, error)); ok { | |||
| 141 | + | return returnFunc(ctx, p, repo, rev, path, includeLastCommit) | |||
| 142 | + | } | |||
| 143 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath, bool) domain.Tree); ok { | |||
| 144 | + | r0 = returnFunc(ctx, p, repo, rev, path, includeLastCommit) | |||
| 145 | + | } else { | |||
| 146 | + | r0 = ret.Get(0).(domain.Tree) | |||
| 147 | + | } | |||
| 148 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath, bool) error); ok { | |||
| 149 | + | r1 = returnFunc(ctx, p, repo, rev, path, includeLastCommit) | |||
| 150 | + | } else { | |||
| 151 | + | r1 = ret.Error(1) | |||
| 152 | + | } | |||
| 153 | + | return r0, r1 | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // BrowseService_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree' | |||
| 157 | + | type BrowseService_GetTree_Call struct { | |||
| 158 | + | *mock.Call | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // GetTree is a helper method to define mock.On call | |||
| 162 | + | // - ctx context.Context | |||
| 163 | + | // - p domain.Principal | |||
| 164 | + | // - repo domain.RepoName | |||
| 165 | + | // - rev domain.Revision | |||
| 166 | + | // - path domain.TreePath | |||
| 167 | + | // - includeLastCommit bool | |||
| 168 | + | func (_e *BrowseService_Expecter) GetTree(ctx any, p any, repo any, rev any, path any, includeLastCommit any) *BrowseService_GetTree_Call { | |||
| 169 | + | return &BrowseService_GetTree_Call{Call: _e.mock.On("GetTree", ctx, p, repo, rev, path, includeLastCommit)} | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (_c *BrowseService_GetTree_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool)) *BrowseService_GetTree_Call { | |||
| 173 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 174 | + | var arg0 context.Context | |||
| 175 | + | if args[0] != nil { | |||
| 176 | + | arg0 = args[0].(context.Context) | |||
| 177 | + | } | |||
| 178 | + | var arg1 domain.Principal | |||
| 179 | + | if args[1] != nil { | |||
| 180 | + | arg1 = args[1].(domain.Principal) | |||
| 181 | + | } | |||
| 182 | + | var arg2 domain.RepoName | |||
| 183 | + | if args[2] != nil { | |||
| 184 | + | arg2 = args[2].(domain.RepoName) | |||
| 185 | + | } | |||
| 186 | + | var arg3 domain.Revision | |||
| 187 | + | if args[3] != nil { | |||
| 188 | + | arg3 = args[3].(domain.Revision) | |||
| 189 | + | } | |||
| 190 | + | var arg4 domain.TreePath | |||
| 191 | + | if args[4] != nil { | |||
| 192 | + | arg4 = args[4].(domain.TreePath) | |||
| 193 | + | } | |||
| 194 | + | var arg5 bool | |||
| 195 | + | if args[5] != nil { | |||
| 196 | + | arg5 = args[5].(bool) | |||
| 197 | + | } | |||
| 198 | + | run( | |||
| 199 | + | arg0, | |||
| 200 | + | arg1, | |||
| 201 | + | arg2, | |||
| 202 | + | arg3, | |||
| 203 | + | arg4, | |||
| 204 | + | arg5, | |||
| 205 | + | ) | |||
| 206 | + | }) | |||
| 207 | + | return _c | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (_c *BrowseService_GetTree_Call) Return(tree domain.Tree, err error) *BrowseService_GetTree_Call { | |||
| 211 | + | _c.Call.Return(tree, err) | |||
| 212 | + | return _c | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (_c *BrowseService_GetTree_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)) *BrowseService_GetTree_Call { | |||
| 216 | + | _c.Call.Return(run) | |||
| 217 | + | return _c | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // ListCommits provides a mock function for the type BrowseService | |||
| 221 | + | func (_mock *BrowseService) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error) { | |||
| 222 | + | ret := _mock.Called(ctx, p, repo, params) | |||
| 223 | + | ||||
| 224 | + | if len(ret) == 0 { | |||
| 225 | + | panic("no return value specified for ListCommits") | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | var r0 ports.HistoryPage | |||
| 229 | + | var r1 error | |||
| 230 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error)); ok { | |||
| 231 | + | return returnFunc(ctx, p, repo, params) | |||
| 232 | + | } | |||
| 233 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) ports.HistoryPage); ok { | |||
| 234 | + | r0 = returnFunc(ctx, p, repo, params) | |||
| 235 | + | } else { | |||
| 236 | + | r0 = ret.Get(0).(ports.HistoryPage) | |||
| 237 | + | } | |||
| 238 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) error); ok { | |||
| 239 | + | r1 = returnFunc(ctx, p, repo, params) | |||
| 240 | + | } else { | |||
| 241 | + | r1 = ret.Error(1) | |||
| 242 | + | } | |||
| 243 | + | return r0, r1 | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // BrowseService_ListCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCommits' | |||
| 247 | + | type BrowseService_ListCommits_Call struct { | |||
| 248 | + | *mock.Call | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // ListCommits is a helper method to define mock.On call | |||
| 252 | + | // - ctx context.Context | |||
| 253 | + | // - p domain.Principal | |||
| 254 | + | // - repo domain.RepoName | |||
| 255 | + | // - params ports.ListCommitsParams | |||
| 256 | + | func (_e *BrowseService_Expecter) ListCommits(ctx any, p any, repo any, params any) *BrowseService_ListCommits_Call { | |||
| 257 | + | return &BrowseService_ListCommits_Call{Call: _e.mock.On("ListCommits", ctx, p, repo, params)} | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func (_c *BrowseService_ListCommits_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams)) *BrowseService_ListCommits_Call { | |||
| 261 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 262 | + | var arg0 context.Context | |||
| 263 | + | if args[0] != nil { | |||
| 264 | + | arg0 = args[0].(context.Context) | |||
| 265 | + | } | |||
| 266 | + | var arg1 domain.Principal | |||
| 267 | + | if args[1] != nil { | |||
| 268 | + | arg1 = args[1].(domain.Principal) | |||
| 269 | + | } | |||
| 270 | + | var arg2 domain.RepoName | |||
| 271 | + | if args[2] != nil { | |||
| 272 | + | arg2 = args[2].(domain.RepoName) | |||
| 273 | + | } | |||
| 274 | + | var arg3 ports.ListCommitsParams | |||
| 275 | + | if args[3] != nil { | |||
| 276 | + | arg3 = args[3].(ports.ListCommitsParams) | |||
| 277 | + | } | |||
| 278 | + | run( | |||
| 279 | + | arg0, | |||
| 280 | + | arg1, | |||
| 281 | + | arg2, | |||
| 282 | + | arg3, | |||
| 283 | + | ) | |||
| 284 | + | }) | |||
| 285 | + | return _c | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (_c *BrowseService_ListCommits_Call) Return(historyPage ports.HistoryPage, err error) *BrowseService_ListCommits_Call { | |||
| 289 | + | _c.Call.Return(historyPage, err) | |||
| 290 | + | return _c | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func (_c *BrowseService_ListCommits_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error)) *BrowseService_ListCommits_Call { | |||
| 294 | + | _c.Call.Return(run) | |||
| 295 | + | return _c | |||
| 296 | + | } | |||
| 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 | + | ||||
| 376 | + | // ListRefs provides a mock function for the type BrowseService | |||
| 377 | + | func (_mock *BrowseService) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) { | |||
| 378 | + | ret := _mock.Called(ctx, p, repo, params) | |||
| 379 | + | ||||
| 380 | + | if len(ret) == 0 { | |||
| 381 | + | panic("no return value specified for ListRefs") | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | var r0 ports.RefPage | |||
| 385 | + | var r1 error | |||
| 386 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRefsParams) (ports.RefPage, error)); ok { | |||
| 387 | + | return returnFunc(ctx, p, repo, params) | |||
| 388 | + | } | |||
| 389 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRefsParams) ports.RefPage); ok { | |||
| 390 | + | r0 = returnFunc(ctx, p, repo, params) | |||
| 391 | + | } else { | |||
| 392 | + | r0 = ret.Get(0).(ports.RefPage) | |||
| 393 | + | } | |||
| 394 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRefsParams) error); ok { | |||
| 395 | + | r1 = returnFunc(ctx, p, repo, params) | |||
| 396 | + | } else { | |||
| 397 | + | r1 = ret.Error(1) | |||
| 398 | + | } | |||
| 399 | + | return r0, r1 | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // BrowseService_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs' | |||
| 403 | + | type BrowseService_ListRefs_Call struct { | |||
| 404 | + | *mock.Call | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // ListRefs is a helper method to define mock.On call | |||
| 408 | + | // - ctx context.Context | |||
| 409 | + | // - p domain.Principal | |||
| 410 | + | // - repo domain.RepoName | |||
| 411 | + | // - params ports.ListRefsParams | |||
| 412 | + | func (_e *BrowseService_Expecter) ListRefs(ctx any, p any, repo any, params any) *BrowseService_ListRefs_Call { | |||
| 413 | + | return &BrowseService_ListRefs_Call{Call: _e.mock.On("ListRefs", ctx, p, repo, params)} | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func (_c *BrowseService_ListRefs_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams)) *BrowseService_ListRefs_Call { | |||
| 417 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 418 | + | var arg0 context.Context | |||
| 419 | + | if args[0] != nil { | |||
| 420 | + | arg0 = args[0].(context.Context) | |||
| 421 | + | } | |||
| 422 | + | var arg1 domain.Principal | |||
| 423 | + | if args[1] != nil { | |||
| 424 | + | arg1 = args[1].(domain.Principal) | |||
| 425 | + | } | |||
| 426 | + | var arg2 domain.RepoName | |||
| 427 | + | if args[2] != nil { | |||
| 428 | + | arg2 = args[2].(domain.RepoName) | |||
| 429 | + | } | |||
| 430 | + | var arg3 ports.ListRefsParams | |||
| 431 | + | if args[3] != nil { | |||
| 432 | + | arg3 = args[3].(ports.ListRefsParams) | |||
| 433 | + | } | |||
| 434 | + | run( | |||
| 435 | + | arg0, | |||
| 436 | + | arg1, | |||
| 437 | + | arg2, | |||
| 438 | + | arg3, | |||
| 439 | + | ) | |||
| 440 | + | }) | |||
| 441 | + | return _c | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | func (_c *BrowseService_ListRefs_Call) Return(refPage ports.RefPage, err error) *BrowseService_ListRefs_Call { | |||
| 445 | + | _c.Call.Return(refPage, err) | |||
| 446 | + | return _c | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func (_c *BrowseService_ListRefs_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error)) *BrowseService_ListRefs_Call { | |||
| 450 | + | _c.Call.Return(run) | |||
| 451 | + | return _c | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // OpenBlob provides a mock function for the type BrowseService | |||
| 455 | + | func (_mock *BrowseService) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 456 | + | ret := _mock.Called(ctx, p, repo, rev, path) | |||
| 457 | + | ||||
| 458 | + | if len(ret) == 0 { | |||
| 459 | + | panic("no return value specified for OpenBlob") | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | var r0 domain.BlobInfo | |||
| 463 | + | var r1 io.ReadCloser | |||
| 464 | + | var r2 error | |||
| 465 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)); ok { | |||
| 466 | + | return returnFunc(ctx, p, repo, rev, path) | |||
| 467 | + | } | |||
| 468 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) domain.BlobInfo); ok { | |||
| 469 | + | r0 = returnFunc(ctx, p, repo, rev, path) | |||
| 470 | + | } else { | |||
| 471 | + | r0 = ret.Get(0).(domain.BlobInfo) | |||
| 472 | + | } | |||
| 473 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) io.ReadCloser); ok { | |||
| 474 | + | r1 = returnFunc(ctx, p, repo, rev, path) | |||
| 475 | + | } else { | |||
| 476 | + | if ret.Get(1) != nil { | |||
| 477 | + | r1 = ret.Get(1).(io.ReadCloser) | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) error); ok { | |||
| 481 | + | r2 = returnFunc(ctx, p, repo, rev, path) | |||
| 482 | + | } else { | |||
| 483 | + | r2 = ret.Error(2) | |||
| 484 | + | } | |||
| 485 | + | return r0, r1, r2 | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | // BrowseService_OpenBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenBlob' | |||
| 489 | + | type BrowseService_OpenBlob_Call struct { | |||
| 490 | + | *mock.Call | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | // OpenBlob is a helper method to define mock.On call | |||
| 494 | + | // - ctx context.Context | |||
| 495 | + | // - p domain.Principal | |||
| 496 | + | // - repo domain.RepoName | |||
| 497 | + | // - rev domain.Revision | |||
| 498 | + | // - path domain.TreePath | |||
| 499 | + | func (_e *BrowseService_Expecter) OpenBlob(ctx any, p any, repo any, rev any, path any) *BrowseService_OpenBlob_Call { | |||
| 500 | + | return &BrowseService_OpenBlob_Call{Call: _e.mock.On("OpenBlob", ctx, p, repo, rev, path)} | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func (_c *BrowseService_OpenBlob_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath)) *BrowseService_OpenBlob_Call { | |||
| 504 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 505 | + | var arg0 context.Context | |||
| 506 | + | if args[0] != nil { | |||
| 507 | + | arg0 = args[0].(context.Context) | |||
| 508 | + | } | |||
| 509 | + | var arg1 domain.Principal | |||
| 510 | + | if args[1] != nil { | |||
| 511 | + | arg1 = args[1].(domain.Principal) | |||
| 512 | + | } | |||
| 513 | + | var arg2 domain.RepoName | |||
| 514 | + | if args[2] != nil { | |||
| 515 | + | arg2 = args[2].(domain.RepoName) | |||
| 516 | + | } | |||
| 517 | + | var arg3 domain.Revision | |||
| 518 | + | if args[3] != nil { | |||
| 519 | + | arg3 = args[3].(domain.Revision) | |||
| 520 | + | } | |||
| 521 | + | var arg4 domain.TreePath | |||
| 522 | + | if args[4] != nil { | |||
| 523 | + | arg4 = args[4].(domain.TreePath) | |||
| 524 | + | } | |||
| 525 | + | run( | |||
| 526 | + | arg0, | |||
| 527 | + | arg1, | |||
| 528 | + | arg2, | |||
| 529 | + | arg3, | |||
| 530 | + | arg4, | |||
| 531 | + | ) | |||
| 532 | + | }) | |||
| 533 | + | return _c | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | func (_c *BrowseService_OpenBlob_Call) Return(blobInfo domain.BlobInfo, readCloser io.ReadCloser, err error) *BrowseService_OpenBlob_Call { | |||
| 537 | + | _c.Call.Return(blobInfo, readCloser, err) | |||
| 538 | + | return _c | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | func (_c *BrowseService_OpenBlob_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)) *BrowseService_OpenBlob_Call { | |||
| 542 | + | _c.Call.Return(run) | |||
| 543 | + | return _c | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | // ResolveRevision provides a mock function for the type BrowseService | |||
| 547 | + | func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |||
| 548 | + | ret := _mock.Called(ctx, p, repo, rev) | |||
| 549 | + | ||||
| 550 | + | if len(ret) == 0 { | |||
| 551 | + | panic("no return value specified for ResolveRevision") | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | var r0 domain.ResolvedRevision | |||
| 555 | + | var r1 error | |||
| 556 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.ResolvedRevision, error)); ok { | |||
| 557 | + | return returnFunc(ctx, p, repo, rev) | |||
| 558 | + | } | |||
| 559 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.ResolvedRevision); ok { | |||
| 560 | + | r0 = returnFunc(ctx, p, repo, rev) | |||
| 561 | + | } else { | |||
| 562 | + | r0 = ret.Get(0).(domain.ResolvedRevision) | |||
| 563 | + | } | |||
| 564 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok { | |||
| 565 | + | r1 = returnFunc(ctx, p, repo, rev) | |||
| 566 | + | } else { | |||
| 567 | + | r1 = ret.Error(1) | |||
| 568 | + | } | |||
| 569 | + | return r0, r1 | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | // BrowseService_ResolveRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRevision' | |||
| 573 | + | type BrowseService_ResolveRevision_Call struct { | |||
| 574 | + | *mock.Call | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | // ResolveRevision is a helper method to define mock.On call | |||
| 578 | + | // - ctx context.Context | |||
| 579 | + | // - p domain.Principal | |||
| 580 | + | // - repo domain.RepoName | |||
| 581 | + | // - rev domain.Revision | |||
| 582 | + | func (_e *BrowseService_Expecter) ResolveRevision(ctx any, p any, repo any, rev any) *BrowseService_ResolveRevision_Call { | |||
| 583 | + | return &BrowseService_ResolveRevision_Call{Call: _e.mock.On("ResolveRevision", ctx, p, repo, rev)} | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | func (_c *BrowseService_ResolveRevision_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_ResolveRevision_Call { | |||
| 587 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 588 | + | var arg0 context.Context | |||
| 589 | + | if args[0] != nil { | |||
| 590 | + | arg0 = args[0].(context.Context) | |||
| 591 | + | } | |||
| 592 | + | var arg1 domain.Principal | |||
| 593 | + | if args[1] != nil { | |||
| 594 | + | arg1 = args[1].(domain.Principal) | |||
| 595 | + | } | |||
| 596 | + | var arg2 domain.RepoName | |||
| 597 | + | if args[2] != nil { | |||
| 598 | + | arg2 = args[2].(domain.RepoName) | |||
| 599 | + | } | |||
| 600 | + | var arg3 domain.Revision | |||
| 601 | + | if args[3] != nil { | |||
| 602 | + | arg3 = args[3].(domain.Revision) | |||
| 603 | + | } | |||
| 604 | + | run( | |||
| 605 | + | arg0, | |||
| 606 | + | arg1, | |||
| 607 | + | arg2, | |||
| 608 | + | arg3, | |||
| 609 | + | ) | |||
| 610 | + | }) | |||
| 611 | + | return _c | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | func (_c *BrowseService_ResolveRevision_Call) Return(resolvedRevision domain.ResolvedRevision, err error) *BrowseService_ResolveRevision_Call { | |||
| 615 | + | _c.Call.Return(resolvedRevision, err) | |||
| 616 | + | return _c | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | func (_c *BrowseService_ResolveRevision_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)) *BrowseService_ResolveRevision_Call { | |||
| 620 | + | _c.Call.Return(run) | |||
| 621 | + | return _c | |||
| 622 | + | } | |||
A internal/ports/mocks/EventService.go +118 -0
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewEventService creates a new instance of EventService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewEventService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *EventService { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &EventService{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // EventService is an autogenerated mock type for the EventService type | |||
| 39 | + | type EventService struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type EventService_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *EventService) EXPECT() *EventService_Expecter { | |||
| 48 | + | return &EventService_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Watch provides a mock function for the type EventService | |||
| 52 | + | func (_mock *EventService) Watch(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error { | |||
| 53 | + | ret := _mock.Called(ctx, p, params, send) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for Watch") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.WatchParams, func(ports.WatchEvent) error) error); ok { | |||
| 61 | + | r0 = returnFunc(ctx, p, params, send) | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Error(0) | |||
| 64 | + | } | |||
| 65 | + | return r0 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // EventService_Watch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Watch' | |||
| 69 | + | type EventService_Watch_Call struct { | |||
| 70 | + | *mock.Call | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Watch is a helper method to define mock.On call | |||
| 74 | + | // - ctx context.Context | |||
| 75 | + | // - p domain.Principal | |||
| 76 | + | // - params ports.WatchParams | |||
| 77 | + | // - send func(ports.WatchEvent) error | |||
| 78 | + | func (_e *EventService_Expecter) Watch(ctx any, p any, params any, send any) *EventService_Watch_Call { | |||
| 79 | + | return &EventService_Watch_Call{Call: _e.mock.On("Watch", ctx, p, params, send)} | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *EventService_Watch_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error)) *EventService_Watch_Call { | |||
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 84 | + | var arg0 context.Context | |||
| 85 | + | if args[0] != nil { | |||
| 86 | + | arg0 = args[0].(context.Context) | |||
| 87 | + | } | |||
| 88 | + | var arg1 domain.Principal | |||
| 89 | + | if args[1] != nil { | |||
| 90 | + | arg1 = args[1].(domain.Principal) | |||
| 91 | + | } | |||
| 92 | + | var arg2 ports.WatchParams | |||
| 93 | + | if args[2] != nil { | |||
| 94 | + | arg2 = args[2].(ports.WatchParams) | |||
| 95 | + | } | |||
| 96 | + | var arg3 func(ports.WatchEvent) error | |||
| 97 | + | if args[3] != nil { | |||
| 98 | + | arg3 = args[3].(func(ports.WatchEvent) error) | |||
| 99 | + | } | |||
| 100 | + | run( | |||
| 101 | + | arg0, | |||
| 102 | + | arg1, | |||
| 103 | + | arg2, | |||
| 104 | + | arg3, | |||
| 105 | + | ) | |||
| 106 | + | }) | |||
| 107 | + | return _c | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (_c *EventService_Watch_Call) Return(err error) *EventService_Watch_Call { | |||
| 111 | + | _c.Call.Return(err) | |||
| 112 | + | return _c | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (_c *EventService_Watch_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error) *EventService_Watch_Call { | |||
| 116 | + | _c.Call.Return(run) | |||
| 117 | + | return _c | |||
| 118 | + | } | |||
A internal/ports/mocks/FailureLimiter.go +160 -0
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewFailureLimiter creates a new instance of FailureLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewFailureLimiter(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *FailureLimiter { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &FailureLimiter{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // FailureLimiter is an autogenerated mock type for the FailureLimiter type | |||
| 38 | + | type FailureLimiter struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type FailureLimiter_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *FailureLimiter) EXPECT() *FailureLimiter_Expecter { | |||
| 47 | + | return &FailureLimiter_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Blocked provides a mock function for the type FailureLimiter | |||
| 51 | + | func (_mock *FailureLimiter) Blocked(ctx context.Context, addr string) (bool, time.Duration) { | |||
| 52 | + | ret := _mock.Called(ctx, addr) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Blocked") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 bool | |||
| 59 | + | var r1 time.Duration | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, time.Duration)); ok { | |||
| 61 | + | return returnFunc(ctx, addr) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | |||
| 64 | + | r0 = returnFunc(ctx, addr) | |||
| 65 | + | } else { | |||
| 66 | + | r0 = ret.Get(0).(bool) | |||
| 67 | + | } | |||
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) time.Duration); ok { | |||
| 69 | + | r1 = returnFunc(ctx, addr) | |||
| 70 | + | } else { | |||
| 71 | + | r1 = ret.Get(1).(time.Duration) | |||
| 72 | + | } | |||
| 73 | + | return r0, r1 | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // FailureLimiter_Blocked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Blocked' | |||
| 77 | + | type FailureLimiter_Blocked_Call struct { | |||
| 78 | + | *mock.Call | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Blocked is a helper method to define mock.On call | |||
| 82 | + | // - ctx context.Context | |||
| 83 | + | // - addr string | |||
| 84 | + | func (_e *FailureLimiter_Expecter) Blocked(ctx any, addr any) *FailureLimiter_Blocked_Call { | |||
| 85 | + | return &FailureLimiter_Blocked_Call{Call: _e.mock.On("Blocked", ctx, addr)} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (_c *FailureLimiter_Blocked_Call) Run(run func(ctx context.Context, addr string)) *FailureLimiter_Blocked_Call { | |||
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 90 | + | var arg0 context.Context | |||
| 91 | + | if args[0] != nil { | |||
| 92 | + | arg0 = args[0].(context.Context) | |||
| 93 | + | } | |||
| 94 | + | var arg1 string | |||
| 95 | + | if args[1] != nil { | |||
| 96 | + | arg1 = args[1].(string) | |||
| 97 | + | } | |||
| 98 | + | run( | |||
| 99 | + | arg0, | |||
| 100 | + | arg1, | |||
| 101 | + | ) | |||
| 102 | + | }) | |||
| 103 | + | return _c | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (_c *FailureLimiter_Blocked_Call) Return(b bool, duration time.Duration) *FailureLimiter_Blocked_Call { | |||
| 107 | + | _c.Call.Return(b, duration) | |||
| 108 | + | return _c | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (_c *FailureLimiter_Blocked_Call) RunAndReturn(run func(ctx context.Context, addr string) (bool, time.Duration)) *FailureLimiter_Blocked_Call { | |||
| 112 | + | _c.Call.Return(run) | |||
| 113 | + | return _c | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Fail provides a mock function for the type FailureLimiter | |||
| 117 | + | func (_mock *FailureLimiter) Fail(ctx context.Context, addr string) { | |||
| 118 | + | _mock.Called(ctx, addr) | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // FailureLimiter_Fail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fail' | |||
| 123 | + | type FailureLimiter_Fail_Call struct { | |||
| 124 | + | *mock.Call | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // Fail is a helper method to define mock.On call | |||
| 128 | + | // - ctx context.Context | |||
| 129 | + | // - addr string | |||
| 130 | + | func (_e *FailureLimiter_Expecter) Fail(ctx any, addr any) *FailureLimiter_Fail_Call { | |||
| 131 | + | return &FailureLimiter_Fail_Call{Call: _e.mock.On("Fail", ctx, addr)} | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (_c *FailureLimiter_Fail_Call) Run(run func(ctx context.Context, addr string)) *FailureLimiter_Fail_Call { | |||
| 135 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 136 | + | var arg0 context.Context | |||
| 137 | + | if args[0] != nil { | |||
| 138 | + | arg0 = args[0].(context.Context) | |||
| 139 | + | } | |||
| 140 | + | var arg1 string | |||
| 141 | + | if args[1] != nil { | |||
| 142 | + | arg1 = args[1].(string) | |||
| 143 | + | } | |||
| 144 | + | run( | |||
| 145 | + | arg0, | |||
| 146 | + | arg1, | |||
| 147 | + | ) | |||
| 148 | + | }) | |||
| 149 | + | return _c | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (_c *FailureLimiter_Fail_Call) Return() *FailureLimiter_Fail_Call { | |||
| 153 | + | _c.Call.Return() | |||
| 154 | + | return _c | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (_c *FailureLimiter_Fail_Call) RunAndReturn(run func(ctx context.Context, addr string)) *FailureLimiter_Fail_Call { | |||
| 158 | + | _c.Run(run) | |||
| 159 | + | return _c | |||
| 160 | + | } | |||
A internal/ports/mocks/HookTokens.go +166 -0
| @@ -0,0 +1,166 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewHookTokens creates a new instance of HookTokens. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewHookTokens(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *HookTokens { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &HookTokens{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // HookTokens is an autogenerated mock type for the HookTokens type | |||
| 38 | + | type HookTokens struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type HookTokens_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *HookTokens) EXPECT() *HookTokens_Expecter { | |||
| 47 | + | return &HookTokens_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Register provides a mock function for the type HookTokens | |||
| 51 | + | func (_mock *HookTokens) Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func()) { | |||
| 52 | + | ret := _mock.Called(ctx, repo, p) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Register") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 string | |||
| 59 | + | var r1 func() | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) (string, func())); ok { | |||
| 61 | + | return returnFunc(ctx, repo, p) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) string); ok { | |||
| 64 | + | r0 = returnFunc(ctx, repo, p) | |||
| 65 | + | } else { | |||
| 66 | + | r0 = ret.Get(0).(string) | |||
| 67 | + | } | |||
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Principal) func()); ok { | |||
| 69 | + | r1 = returnFunc(ctx, repo, p) | |||
| 70 | + | } else { | |||
| 71 | + | if ret.Get(1) != nil { | |||
| 72 | + | r1 = ret.Get(1).(func()) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return r0, r1 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // HookTokens_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' | |||
| 79 | + | type HookTokens_Register_Call struct { | |||
| 80 | + | *mock.Call | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Register is a helper method to define mock.On call | |||
| 84 | + | // - ctx context.Context | |||
| 85 | + | // - repo domain.RepoName | |||
| 86 | + | // - p domain.Principal | |||
| 87 | + | func (_e *HookTokens_Expecter) Register(ctx any, repo any, p any) *HookTokens_Register_Call { | |||
| 88 | + | return &HookTokens_Register_Call{Call: _e.mock.On("Register", ctx, repo, p)} | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (_c *HookTokens_Register_Call) Run(run func(ctx context.Context, repo domain.RepoName, p domain.Principal)) *HookTokens_Register_Call { | |||
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 93 | + | var arg0 context.Context | |||
| 94 | + | if args[0] != nil { | |||
| 95 | + | arg0 = args[0].(context.Context) | |||
| 96 | + | } | |||
| 97 | + | var arg1 domain.RepoName | |||
| 98 | + | if args[1] != nil { | |||
| 99 | + | arg1 = args[1].(domain.RepoName) | |||
| 100 | + | } | |||
| 101 | + | var arg2 domain.Principal | |||
| 102 | + | if args[2] != nil { | |||
| 103 | + | arg2 = args[2].(domain.Principal) | |||
| 104 | + | } | |||
| 105 | + | run( | |||
| 106 | + | arg0, | |||
| 107 | + | arg1, | |||
| 108 | + | arg2, | |||
| 109 | + | ) | |||
| 110 | + | }) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (_c *HookTokens_Register_Call) Return(token string, release func()) *HookTokens_Register_Call { | |||
| 115 | + | _c.Call.Return(token, release) | |||
| 116 | + | return _c | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (_c *HookTokens_Register_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func())) *HookTokens_Register_Call { | |||
| 120 | + | _c.Call.Return(run) | |||
| 121 | + | return _c | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Socket provides a mock function for the type HookTokens | |||
| 125 | + | func (_mock *HookTokens) Socket() string { | |||
| 126 | + | ret := _mock.Called() | |||
| 127 | + | ||||
| 128 | + | if len(ret) == 0 { | |||
| 129 | + | panic("no return value specified for Socket") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | var r0 string | |||
| 133 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 134 | + | r0 = returnFunc() | |||
| 135 | + | } else { | |||
| 136 | + | r0 = ret.Get(0).(string) | |||
| 137 | + | } | |||
| 138 | + | return r0 | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // HookTokens_Socket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Socket' | |||
| 142 | + | type HookTokens_Socket_Call struct { | |||
| 143 | + | *mock.Call | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Socket is a helper method to define mock.On call | |||
| 147 | + | func (_e *HookTokens_Expecter) Socket() *HookTokens_Socket_Call { | |||
| 148 | + | return &HookTokens_Socket_Call{Call: _e.mock.On("Socket")} | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func (_c *HookTokens_Socket_Call) Run(run func()) *HookTokens_Socket_Call { | |||
| 152 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 153 | + | run() | |||
| 154 | + | }) | |||
| 155 | + | return _c | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (_c *HookTokens_Socket_Call) Return(s string) *HookTokens_Socket_Call { | |||
| 159 | + | _c.Call.Return(s) | |||
| 160 | + | return _c | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (_c *HookTokens_Socket_Call) RunAndReturn(run func() string) *HookTokens_Socket_Call { | |||
| 164 | + | _c.Call.Return(run) | |||
| 165 | + | return _c | |||
| 166 | + | } | |||
A internal/ports/mocks/MaintenanceScheduler.go +94 -0
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewMaintenanceScheduler creates a new instance of MaintenanceScheduler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewMaintenanceScheduler(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *MaintenanceScheduler { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &MaintenanceScheduler{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // MaintenanceScheduler is an autogenerated mock type for the MaintenanceScheduler type | |||
| 38 | + | type MaintenanceScheduler struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type MaintenanceScheduler_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *MaintenanceScheduler) EXPECT() *MaintenanceScheduler_Expecter { | |||
| 47 | + | return &MaintenanceScheduler_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Schedule provides a mock function for the type MaintenanceScheduler | |||
| 51 | + | func (_mock *MaintenanceScheduler) Schedule(ctx context.Context, name domain.RepoName) { | |||
| 52 | + | _mock.Called(ctx, name) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // MaintenanceScheduler_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule' | |||
| 57 | + | type MaintenanceScheduler_Schedule_Call struct { | |||
| 58 | + | *mock.Call | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Schedule is a helper method to define mock.On call | |||
| 62 | + | // - ctx context.Context | |||
| 63 | + | // - name domain.RepoName | |||
| 64 | + | func (_e *MaintenanceScheduler_Expecter) Schedule(ctx any, name any) *MaintenanceScheduler_Schedule_Call { | |||
| 65 | + | return &MaintenanceScheduler_Schedule_Call{Call: _e.mock.On("Schedule", ctx, name)} | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (_c *MaintenanceScheduler_Schedule_Call) Run(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |||
| 69 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 70 | + | var arg0 context.Context | |||
| 71 | + | if args[0] != nil { | |||
| 72 | + | arg0 = args[0].(context.Context) | |||
| 73 | + | } | |||
| 74 | + | var arg1 domain.RepoName | |||
| 75 | + | if args[1] != nil { | |||
| 76 | + | arg1 = args[1].(domain.RepoName) | |||
| 77 | + | } | |||
| 78 | + | run( | |||
| 79 | + | arg0, | |||
| 80 | + | arg1, | |||
| 81 | + | ) | |||
| 82 | + | }) | |||
| 83 | + | return _c | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (_c *MaintenanceScheduler_Schedule_Call) Return() *MaintenanceScheduler_Schedule_Call { | |||
| 87 | + | _c.Call.Return() | |||
| 88 | + | return _c | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (_c *MaintenanceScheduler_Schedule_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |||
| 92 | + | _c.Run(run) | |||
| 93 | + | return _c | |||
| 94 | + | } | |||
A internal/ports/mocks/MergeRequestService.go +709 -0
| @@ -0,0 +1,709 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewMergeRequestService creates a new instance of MergeRequestService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewMergeRequestService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *MergeRequestService { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &MergeRequestService{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // MergeRequestService is an autogenerated mock type for the MergeRequestService type | |||
| 39 | + | type MergeRequestService struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type MergeRequestService_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *MergeRequestService) EXPECT() *MergeRequestService_Expecter { | |||
| 48 | + | return &MergeRequestService_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // AddComment provides a mock function for the type MergeRequestService | |||
| 52 | + | func (_mock *MergeRequestService) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams) (domain.Comment, error) { | |||
| 53 | + | ret := _mock.Called(ctx, p, name, number, params) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for AddComment") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 domain.Comment | |||
| 60 | + | var r1 error | |||
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.AddCommentParams) (domain.Comment, error)); ok { | |||
| 62 | + | return returnFunc(ctx, p, name, number, params) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.AddCommentParams) domain.Comment); ok { | |||
| 65 | + | r0 = returnFunc(ctx, p, name, number, params) | |||
| 66 | + | } else { | |||
| 67 | + | r0 = ret.Get(0).(domain.Comment) | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.AddCommentParams) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx, p, name, number, params) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // MergeRequestService_AddComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddComment' | |||
| 78 | + | type MergeRequestService_AddComment_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // AddComment is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | // - p domain.Principal | |||
| 85 | + | // - name domain.RepoName | |||
| 86 | + | // - number int64 | |||
| 87 | + | // - params ports.AddCommentParams | |||
| 88 | + | func (_e *MergeRequestService_Expecter) AddComment(ctx any, p any, name any, number any, params any) *MergeRequestService_AddComment_Call { | |||
| 89 | + | return &MergeRequestService_AddComment_Call{Call: _e.mock.On("AddComment", ctx, p, name, number, params)} | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *MergeRequestService_AddComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams)) *MergeRequestService_AddComment_Call { | |||
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 94 | + | var arg0 context.Context | |||
| 95 | + | if args[0] != nil { | |||
| 96 | + | arg0 = args[0].(context.Context) | |||
| 97 | + | } | |||
| 98 | + | var arg1 domain.Principal | |||
| 99 | + | if args[1] != nil { | |||
| 100 | + | arg1 = args[1].(domain.Principal) | |||
| 101 | + | } | |||
| 102 | + | var arg2 domain.RepoName | |||
| 103 | + | if args[2] != nil { | |||
| 104 | + | arg2 = args[2].(domain.RepoName) | |||
| 105 | + | } | |||
| 106 | + | var arg3 int64 | |||
| 107 | + | if args[3] != nil { | |||
| 108 | + | arg3 = args[3].(int64) | |||
| 109 | + | } | |||
| 110 | + | var arg4 ports.AddCommentParams | |||
| 111 | + | if args[4] != nil { | |||
| 112 | + | arg4 = args[4].(ports.AddCommentParams) | |||
| 113 | + | } | |||
| 114 | + | run( | |||
| 115 | + | arg0, | |||
| 116 | + | arg1, | |||
| 117 | + | arg2, | |||
| 118 | + | arg3, | |||
| 119 | + | arg4, | |||
| 120 | + | ) | |||
| 121 | + | }) | |||
| 122 | + | return _c | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (_c *MergeRequestService_AddComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_AddComment_Call { | |||
| 126 | + | _c.Call.Return(comment, err) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (_c *MergeRequestService_AddComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams) (domain.Comment, error)) *MergeRequestService_AddComment_Call { | |||
| 131 | + | _c.Call.Return(run) | |||
| 132 | + | return _c | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Close provides a mock function for the type MergeRequestService | |||
| 136 | + | func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) { | |||
| 137 | + | ret := _mock.Called(ctx, p, name, number) | |||
| 138 | + | ||||
| 139 | + | if len(ret) == 0 { | |||
| 140 | + | panic("no return value specified for Close") | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | var r0 domain.MergeRequest | |||
| 144 | + | var r1 error | |||
| 145 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.MergeRequest, error)); ok { | |||
| 146 | + | return returnFunc(ctx, p, name, number) | |||
| 147 | + | } | |||
| 148 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.MergeRequest); ok { | |||
| 149 | + | r0 = returnFunc(ctx, p, name, number) | |||
| 150 | + | } else { | |||
| 151 | + | r0 = ret.Get(0).(domain.MergeRequest) | |||
| 152 | + | } | |||
| 153 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |||
| 154 | + | r1 = returnFunc(ctx, p, name, number) | |||
| 155 | + | } else { | |||
| 156 | + | r1 = ret.Error(1) | |||
| 157 | + | } | |||
| 158 | + | return r0, r1 | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // MergeRequestService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |||
| 162 | + | type MergeRequestService_Close_Call struct { | |||
| 163 | + | *mock.Call | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Close is a helper method to define mock.On call | |||
| 167 | + | // - ctx context.Context | |||
| 168 | + | // - p domain.Principal | |||
| 169 | + | // - name domain.RepoName | |||
| 170 | + | // - number int64 | |||
| 171 | + | func (_e *MergeRequestService_Expecter) Close(ctx any, p any, name any, number any) *MergeRequestService_Close_Call { | |||
| 172 | + | return &MergeRequestService_Close_Call{Call: _e.mock.On("Close", ctx, p, name, number)} | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (_c *MergeRequestService_Close_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Close_Call { | |||
| 176 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 177 | + | var arg0 context.Context | |||
| 178 | + | if args[0] != nil { | |||
| 179 | + | arg0 = args[0].(context.Context) | |||
| 180 | + | } | |||
| 181 | + | var arg1 domain.Principal | |||
| 182 | + | if args[1] != nil { | |||
| 183 | + | arg1 = args[1].(domain.Principal) | |||
| 184 | + | } | |||
| 185 | + | var arg2 domain.RepoName | |||
| 186 | + | if args[2] != nil { | |||
| 187 | + | arg2 = args[2].(domain.RepoName) | |||
| 188 | + | } | |||
| 189 | + | var arg3 int64 | |||
| 190 | + | if args[3] != nil { | |||
| 191 | + | arg3 = args[3].(int64) | |||
| 192 | + | } | |||
| 193 | + | run( | |||
| 194 | + | arg0, | |||
| 195 | + | arg1, | |||
| 196 | + | arg2, | |||
| 197 | + | arg3, | |||
| 198 | + | ) | |||
| 199 | + | }) | |||
| 200 | + | return _c | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (_c *MergeRequestService_Close_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_Close_Call { | |||
| 204 | + | _c.Call.Return(mergeRequest, err) | |||
| 205 | + | return _c | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)) *MergeRequestService_Close_Call { | |||
| 209 | + | _c.Call.Return(run) | |||
| 210 | + | return _c | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Create provides a mock function for the type MergeRequestService | |||
| 214 | + | func (_mock *MergeRequestService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error) { | |||
| 215 | + | ret := _mock.Called(ctx, p, name, params) | |||
| 216 | + | ||||
| 217 | + | if len(ret) == 0 { | |||
| 218 | + | panic("no return value specified for Create") | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | var r0 domain.MergeRequest | |||
| 222 | + | var r1 error | |||
| 223 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateMergeRequestParams) (domain.MergeRequest, error)); ok { | |||
| 224 | + | return returnFunc(ctx, p, name, params) | |||
| 225 | + | } | |||
| 226 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateMergeRequestParams) domain.MergeRequest); ok { | |||
| 227 | + | r0 = returnFunc(ctx, p, name, params) | |||
| 228 | + | } else { | |||
| 229 | + | r0 = ret.Get(0).(domain.MergeRequest) | |||
| 230 | + | } | |||
| 231 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateMergeRequestParams) error); ok { | |||
| 232 | + | r1 = returnFunc(ctx, p, name, params) | |||
| 233 | + | } else { | |||
| 234 | + | r1 = ret.Error(1) | |||
| 235 | + | } | |||
| 236 | + | return r0, r1 | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // MergeRequestService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |||
| 240 | + | type MergeRequestService_Create_Call struct { | |||
| 241 | + | *mock.Call | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // Create is a helper method to define mock.On call | |||
| 245 | + | // - ctx context.Context | |||
| 246 | + | // - p domain.Principal | |||
| 247 | + | // - name domain.RepoName | |||
| 248 | + | // - params ports.CreateMergeRequestParams | |||
| 249 | + | func (_e *MergeRequestService_Expecter) Create(ctx any, p any, name any, params any) *MergeRequestService_Create_Call { | |||
| 250 | + | return &MergeRequestService_Create_Call{Call: _e.mock.On("Create", ctx, p, name, params)} | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (_c *MergeRequestService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams)) *MergeRequestService_Create_Call { | |||
| 254 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 255 | + | var arg0 context.Context | |||
| 256 | + | if args[0] != nil { | |||
| 257 | + | arg0 = args[0].(context.Context) | |||
| 258 | + | } | |||
| 259 | + | var arg1 domain.Principal | |||
| 260 | + | if args[1] != nil { | |||
| 261 | + | arg1 = args[1].(domain.Principal) | |||
| 262 | + | } | |||
| 263 | + | var arg2 domain.RepoName | |||
| 264 | + | if args[2] != nil { | |||
| 265 | + | arg2 = args[2].(domain.RepoName) | |||
| 266 | + | } | |||
| 267 | + | var arg3 ports.CreateMergeRequestParams | |||
| 268 | + | if args[3] != nil { | |||
| 269 | + | arg3 = args[3].(ports.CreateMergeRequestParams) | |||
| 270 | + | } | |||
| 271 | + | run( | |||
| 272 | + | arg0, | |||
| 273 | + | arg1, | |||
| 274 | + | arg2, | |||
| 275 | + | arg3, | |||
| 276 | + | ) | |||
| 277 | + | }) | |||
| 278 | + | return _c | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func (_c *MergeRequestService_Create_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_Create_Call { | |||
| 282 | + | _c.Call.Return(mergeRequest, err) | |||
| 283 | + | return _c | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error)) *MergeRequestService_Create_Call { | |||
| 287 | + | _c.Call.Return(run) | |||
| 288 | + | return _c | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // Get provides a mock function for the type MergeRequestService | |||
| 292 | + | func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) { | |||
| 293 | + | ret := _mock.Called(ctx, p, name, number, sinceLastRead) | |||
| 294 | + | ||||
| 295 | + | if len(ret) == 0 { | |||
| 296 | + | panic("no return value specified for Get") | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | var r0 ports.MergeRequestDetail | |||
| 300 | + | var r1 error | |||
| 301 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (ports.MergeRequestDetail, error)); ok { | |||
| 302 | + | return returnFunc(ctx, p, name, number, sinceLastRead) | |||
| 303 | + | } | |||
| 304 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) ports.MergeRequestDetail); ok { | |||
| 305 | + | r0 = returnFunc(ctx, p, name, number, sinceLastRead) | |||
| 306 | + | } else { | |||
| 307 | + | r0 = ret.Get(0).(ports.MergeRequestDetail) | |||
| 308 | + | } | |||
| 309 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok { | |||
| 310 | + | r1 = returnFunc(ctx, p, name, number, sinceLastRead) | |||
| 311 | + | } else { | |||
| 312 | + | r1 = ret.Error(1) | |||
| 313 | + | } | |||
| 314 | + | return r0, r1 | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // MergeRequestService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |||
| 318 | + | type MergeRequestService_Get_Call struct { | |||
| 319 | + | *mock.Call | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // Get is a helper method to define mock.On call | |||
| 323 | + | // - ctx context.Context | |||
| 324 | + | // - p domain.Principal | |||
| 325 | + | // - name domain.RepoName | |||
| 326 | + | // - number int64 | |||
| 327 | + | // - sinceLastRead bool | |||
| 328 | + | func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, sinceLastRead any) *MergeRequestService_Get_Call { | |||
| 329 | + | return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, sinceLastRead)} | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool)) *MergeRequestService_Get_Call { | |||
| 333 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 334 | + | var arg0 context.Context | |||
| 335 | + | if args[0] != nil { | |||
| 336 | + | arg0 = args[0].(context.Context) | |||
| 337 | + | } | |||
| 338 | + | var arg1 domain.Principal | |||
| 339 | + | if args[1] != nil { | |||
| 340 | + | arg1 = args[1].(domain.Principal) | |||
| 341 | + | } | |||
| 342 | + | var arg2 domain.RepoName | |||
| 343 | + | if args[2] != nil { | |||
| 344 | + | arg2 = args[2].(domain.RepoName) | |||
| 345 | + | } | |||
| 346 | + | var arg3 int64 | |||
| 347 | + | if args[3] != nil { | |||
| 348 | + | arg3 = args[3].(int64) | |||
| 349 | + | } | |||
| 350 | + | var arg4 bool | |||
| 351 | + | if args[4] != nil { | |||
| 352 | + | arg4 = args[4].(bool) | |||
| 353 | + | } | |||
| 354 | + | run( | |||
| 355 | + | arg0, | |||
| 356 | + | arg1, | |||
| 357 | + | arg2, | |||
| 358 | + | arg3, | |||
| 359 | + | arg4, | |||
| 360 | + | ) | |||
| 361 | + | }) | |||
| 362 | + | return _c | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func (_c *MergeRequestService_Get_Call) Return(mergeRequestDetail ports.MergeRequestDetail, err error) *MergeRequestService_Get_Call { | |||
| 366 | + | _c.Call.Return(mergeRequestDetail, err) | |||
| 367 | + | return _c | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call { | |||
| 371 | + | _c.Call.Return(run) | |||
| 372 | + | return _c | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // List provides a mock function for the type MergeRequestService | |||
| 376 | + | func (_mock *MergeRequestService) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams) (ports.MergeRequestPage, error) { | |||
| 377 | + | ret := _mock.Called(ctx, p, name, params) | |||
| 378 | + | ||||
| 379 | + | if len(ret) == 0 { | |||
| 380 | + | panic("no return value specified for List") | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | var r0 ports.MergeRequestPage | |||
| 384 | + | var r1 error | |||
| 385 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListMergeRequestsParams) (ports.MergeRequestPage, error)); ok { | |||
| 386 | + | return returnFunc(ctx, p, name, params) | |||
| 387 | + | } | |||
| 388 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListMergeRequestsParams) ports.MergeRequestPage); ok { | |||
| 389 | + | r0 = returnFunc(ctx, p, name, params) | |||
| 390 | + | } else { | |||
| 391 | + | r0 = ret.Get(0).(ports.MergeRequestPage) | |||
| 392 | + | } | |||
| 393 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListMergeRequestsParams) error); ok { | |||
| 394 | + | r1 = returnFunc(ctx, p, name, params) | |||
| 395 | + | } else { | |||
| 396 | + | r1 = ret.Error(1) | |||
| 397 | + | } | |||
| 398 | + | return r0, r1 | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | // MergeRequestService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |||
| 402 | + | type MergeRequestService_List_Call struct { | |||
| 403 | + | *mock.Call | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | // List is a helper method to define mock.On call | |||
| 407 | + | // - ctx context.Context | |||
| 408 | + | // - p domain.Principal | |||
| 409 | + | // - name domain.RepoName | |||
| 410 | + | // - params ports.ListMergeRequestsParams | |||
| 411 | + | func (_e *MergeRequestService_Expecter) List(ctx any, p any, name any, params any) *MergeRequestService_List_Call { | |||
| 412 | + | return &MergeRequestService_List_Call{Call: _e.mock.On("List", ctx, p, name, params)} | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func (_c *MergeRequestService_List_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams)) *MergeRequestService_List_Call { | |||
| 416 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 417 | + | var arg0 context.Context | |||
| 418 | + | if args[0] != nil { | |||
| 419 | + | arg0 = args[0].(context.Context) | |||
| 420 | + | } | |||
| 421 | + | var arg1 domain.Principal | |||
| 422 | + | if args[1] != nil { | |||
| 423 | + | arg1 = args[1].(domain.Principal) | |||
| 424 | + | } | |||
| 425 | + | var arg2 domain.RepoName | |||
| 426 | + | if args[2] != nil { | |||
| 427 | + | arg2 = args[2].(domain.RepoName) | |||
| 428 | + | } | |||
| 429 | + | var arg3 ports.ListMergeRequestsParams | |||
| 430 | + | if args[3] != nil { | |||
| 431 | + | arg3 = args[3].(ports.ListMergeRequestsParams) | |||
| 432 | + | } | |||
| 433 | + | run( | |||
| 434 | + | arg0, | |||
| 435 | + | arg1, | |||
| 436 | + | arg2, | |||
| 437 | + | arg3, | |||
| 438 | + | ) | |||
| 439 | + | }) | |||
| 440 | + | return _c | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func (_c *MergeRequestService_List_Call) Return(mergeRequestPage ports.MergeRequestPage, err error) *MergeRequestService_List_Call { | |||
| 444 | + | _c.Call.Return(mergeRequestPage, err) | |||
| 445 | + | return _c | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func (_c *MergeRequestService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams) (ports.MergeRequestPage, error)) *MergeRequestService_List_Call { | |||
| 449 | + | _c.Call.Return(run) | |||
| 450 | + | return _c | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | // MarkRead provides a mock function for the type MergeRequestService | |||
| 454 | + | func (_mock *MergeRequestService) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) { | |||
| 455 | + | ret := _mock.Called(ctx, p, name, number, head) | |||
| 456 | + | ||||
| 457 | + | if len(ret) == 0 { | |||
| 458 | + | panic("no return value specified for MarkRead") | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | var r0 domain.ObjectID | |||
| 462 | + | var r1 error | |||
| 463 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, domain.ObjectID) (domain.ObjectID, error)); ok { | |||
| 464 | + | return returnFunc(ctx, p, name, number, head) | |||
| 465 | + | } | |||
| 466 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, domain.ObjectID) domain.ObjectID); ok { | |||
| 467 | + | r0 = returnFunc(ctx, p, name, number, head) | |||
| 468 | + | } else { | |||
| 469 | + | r0 = ret.Get(0).(domain.ObjectID) | |||
| 470 | + | } | |||
| 471 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, domain.ObjectID) error); ok { | |||
| 472 | + | r1 = returnFunc(ctx, p, name, number, head) | |||
| 473 | + | } else { | |||
| 474 | + | r1 = ret.Error(1) | |||
| 475 | + | } | |||
| 476 | + | return r0, r1 | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | // MergeRequestService_MarkRead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkRead' | |||
| 480 | + | type MergeRequestService_MarkRead_Call struct { | |||
| 481 | + | *mock.Call | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | // MarkRead is a helper method to define mock.On call | |||
| 485 | + | // - ctx context.Context | |||
| 486 | + | // - p domain.Principal | |||
| 487 | + | // - name domain.RepoName | |||
| 488 | + | // - number int64 | |||
| 489 | + | // - head domain.ObjectID | |||
| 490 | + | func (_e *MergeRequestService_Expecter) MarkRead(ctx any, p any, name any, number any, head any) *MergeRequestService_MarkRead_Call { | |||
| 491 | + | return &MergeRequestService_MarkRead_Call{Call: _e.mock.On("MarkRead", ctx, p, name, number, head)} | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | func (_c *MergeRequestService_MarkRead_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID)) *MergeRequestService_MarkRead_Call { | |||
| 495 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 496 | + | var arg0 context.Context | |||
| 497 | + | if args[0] != nil { | |||
| 498 | + | arg0 = args[0].(context.Context) | |||
| 499 | + | } | |||
| 500 | + | var arg1 domain.Principal | |||
| 501 | + | if args[1] != nil { | |||
| 502 | + | arg1 = args[1].(domain.Principal) | |||
| 503 | + | } | |||
| 504 | + | var arg2 domain.RepoName | |||
| 505 | + | if args[2] != nil { | |||
| 506 | + | arg2 = args[2].(domain.RepoName) | |||
| 507 | + | } | |||
| 508 | + | var arg3 int64 | |||
| 509 | + | if args[3] != nil { | |||
| 510 | + | arg3 = args[3].(int64) | |||
| 511 | + | } | |||
| 512 | + | var arg4 domain.ObjectID | |||
| 513 | + | if args[4] != nil { | |||
| 514 | + | arg4 = args[4].(domain.ObjectID) | |||
| 515 | + | } | |||
| 516 | + | run( | |||
| 517 | + | arg0, | |||
| 518 | + | arg1, | |||
| 519 | + | arg2, | |||
| 520 | + | arg3, | |||
| 521 | + | arg4, | |||
| 522 | + | ) | |||
| 523 | + | }) | |||
| 524 | + | return _c | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | func (_c *MergeRequestService_MarkRead_Call) Return(objectID domain.ObjectID, err error) *MergeRequestService_MarkRead_Call { | |||
| 528 | + | _c.Call.Return(objectID, err) | |||
| 529 | + | return _c | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func (_c *MergeRequestService_MarkRead_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)) *MergeRequestService_MarkRead_Call { | |||
| 533 | + | _c.Call.Return(run) | |||
| 534 | + | return _c | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | // Merge provides a mock function for the type MergeRequestService | |||
| 538 | + | func (_mock *MergeRequestService) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | |||
| 539 | + | ret := _mock.Called(ctx, p, name, number, params) | |||
| 540 | + | ||||
| 541 | + | if len(ret) == 0 { | |||
| 542 | + | panic("no return value specified for Merge") | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | var r0 ports.MergeResult | |||
| 546 | + | var r1 error | |||
| 547 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.MergeParams) (ports.MergeResult, error)); ok { | |||
| 548 | + | return returnFunc(ctx, p, name, number, params) | |||
| 549 | + | } | |||
| 550 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.MergeParams) ports.MergeResult); ok { | |||
| 551 | + | r0 = returnFunc(ctx, p, name, number, params) | |||
| 552 | + | } else { | |||
| 553 | + | r0 = ret.Get(0).(ports.MergeResult) | |||
| 554 | + | } | |||
| 555 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.MergeParams) error); ok { | |||
| 556 | + | r1 = returnFunc(ctx, p, name, number, params) | |||
| 557 | + | } else { | |||
| 558 | + | r1 = ret.Error(1) | |||
| 559 | + | } | |||
| 560 | + | return r0, r1 | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | // MergeRequestService_Merge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Merge' | |||
| 564 | + | type MergeRequestService_Merge_Call struct { | |||
| 565 | + | *mock.Call | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | // Merge is a helper method to define mock.On call | |||
| 569 | + | // - ctx context.Context | |||
| 570 | + | // - p domain.Principal | |||
| 571 | + | // - name domain.RepoName | |||
| 572 | + | // - number int64 | |||
| 573 | + | // - params ports.MergeParams | |||
| 574 | + | func (_e *MergeRequestService_Expecter) Merge(ctx any, p any, name any, number any, params any) *MergeRequestService_Merge_Call { | |||
| 575 | + | return &MergeRequestService_Merge_Call{Call: _e.mock.On("Merge", ctx, p, name, number, params)} | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | func (_c *MergeRequestService_Merge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams)) *MergeRequestService_Merge_Call { | |||
| 579 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 580 | + | var arg0 context.Context | |||
| 581 | + | if args[0] != nil { | |||
| 582 | + | arg0 = args[0].(context.Context) | |||
| 583 | + | } | |||
| 584 | + | var arg1 domain.Principal | |||
| 585 | + | if args[1] != nil { | |||
| 586 | + | arg1 = args[1].(domain.Principal) | |||
| 587 | + | } | |||
| 588 | + | var arg2 domain.RepoName | |||
| 589 | + | if args[2] != nil { | |||
| 590 | + | arg2 = args[2].(domain.RepoName) | |||
| 591 | + | } | |||
| 592 | + | var arg3 int64 | |||
| 593 | + | if args[3] != nil { | |||
| 594 | + | arg3 = args[3].(int64) | |||
| 595 | + | } | |||
| 596 | + | var arg4 ports.MergeParams | |||
| 597 | + | if args[4] != nil { | |||
| 598 | + | arg4 = args[4].(ports.MergeParams) | |||
| 599 | + | } | |||
| 600 | + | run( | |||
| 601 | + | arg0, | |||
| 602 | + | arg1, | |||
| 603 | + | arg2, | |||
| 604 | + | arg3, | |||
| 605 | + | arg4, | |||
| 606 | + | ) | |||
| 607 | + | }) | |||
| 608 | + | return _c | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | func (_c *MergeRequestService_Merge_Call) Return(mergeResult ports.MergeResult, err error) *MergeRequestService_Merge_Call { | |||
| 612 | + | _c.Call.Return(mergeResult, err) | |||
| 613 | + | return _c | |||
| 614 | + | } | |||
| 615 | + | ||||
| 616 | + | func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error)) *MergeRequestService_Merge_Call { | |||
| 617 | + | _c.Call.Return(run) | |||
| 618 | + | return _c | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | // ResolveComment provides a mock function for the type MergeRequestService | |||
| 622 | + | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | |||
| 623 | + | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | |||
| 624 | + | ||||
| 625 | + | if len(ret) == 0 { | |||
| 626 | + | panic("no return value specified for ResolveComment") | |||
| 627 | + | } | |||
| 628 | + | ||||
| 629 | + | var r0 domain.Comment | |||
| 630 | + | var r1 error | |||
| 631 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, bool) (domain.Comment, error)); ok { | |||
| 632 | + | return returnFunc(ctx, p, name, number, commentID, resolved) | |||
| 633 | + | } | |||
| 634 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, bool) domain.Comment); ok { | |||
| 635 | + | r0 = returnFunc(ctx, p, name, number, commentID, resolved) | |||
| 636 | + | } else { | |||
| 637 | + | r0 = ret.Get(0).(domain.Comment) | |||
| 638 | + | } | |||
| 639 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, bool) error); ok { | |||
| 640 | + | r1 = returnFunc(ctx, p, name, number, commentID, resolved) | |||
| 641 | + | } else { | |||
| 642 | + | r1 = ret.Error(1) | |||
| 643 | + | } | |||
| 644 | + | return r0, r1 | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | // MergeRequestService_ResolveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveComment' | |||
| 648 | + | type MergeRequestService_ResolveComment_Call struct { | |||
| 649 | + | *mock.Call | |||
| 650 | + | } | |||
| 651 | + | ||||
| 652 | + | // ResolveComment is a helper method to define mock.On call | |||
| 653 | + | // - ctx context.Context | |||
| 654 | + | // - p domain.Principal | |||
| 655 | + | // - name domain.RepoName | |||
| 656 | + | // - number int64 | |||
| 657 | + | // - commentID int64 | |||
| 658 | + | // - resolved bool | |||
| 659 | + | func (_e *MergeRequestService_Expecter) ResolveComment(ctx any, p any, name any, number any, commentID any, resolved any) *MergeRequestService_ResolveComment_Call { | |||
| 660 | + | return &MergeRequestService_ResolveComment_Call{Call: _e.mock.On("ResolveComment", ctx, p, name, number, commentID, resolved)} | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | func (_c *MergeRequestService_ResolveComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool)) *MergeRequestService_ResolveComment_Call { | |||
| 664 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 665 | + | var arg0 context.Context | |||
| 666 | + | if args[0] != nil { | |||
| 667 | + | arg0 = args[0].(context.Context) | |||
| 668 | + | } | |||
| 669 | + | var arg1 domain.Principal | |||
| 670 | + | if args[1] != nil { | |||
| 671 | + | arg1 = args[1].(domain.Principal) | |||
| 672 | + | } | |||
| 673 | + | var arg2 domain.RepoName | |||
| 674 | + | if args[2] != nil { | |||
| 675 | + | arg2 = args[2].(domain.RepoName) | |||
| 676 | + | } | |||
| 677 | + | var arg3 int64 | |||
| 678 | + | if args[3] != nil { | |||
| 679 | + | arg3 = args[3].(int64) | |||
| 680 | + | } | |||
| 681 | + | var arg4 int64 | |||
| 682 | + | if args[4] != nil { | |||
| 683 | + | arg4 = args[4].(int64) | |||
| 684 | + | } | |||
| 685 | + | var arg5 bool | |||
| 686 | + | if args[5] != nil { | |||
| 687 | + | arg5 = args[5].(bool) | |||
| 688 | + | } | |||
| 689 | + | run( | |||
| 690 | + | arg0, | |||
| 691 | + | arg1, | |||
| 692 | + | arg2, | |||
| 693 | + | arg3, | |||
| 694 | + | arg4, | |||
| 695 | + | arg5, | |||
| 696 | + | ) | |||
| 697 | + | }) | |||
| 698 | + | return _c | |||
| 699 | + | } | |||
| 700 | + | ||||
| 701 | + | func (_c *MergeRequestService_ResolveComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_ResolveComment_Call { | |||
| 702 | + | _c.Call.Return(comment, err) | |||
| 703 | + | return _c | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | func (_c *MergeRequestService_ResolveComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error)) *MergeRequestService_ResolveComment_Call { | |||
| 707 | + | _c.Call.Return(run) | |||
| 708 | + | return _c | |||
| 709 | + | } | |||
A internal/ports/mocks/PushHookService.go +186 -0
| @@ -0,0 +1,186 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewPushHookService creates a new instance of PushHookService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewPushHookService(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *PushHookService { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &PushHookService{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // PushHookService is an autogenerated mock type for the PushHookService type | |||
| 38 | + | type PushHookService struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type PushHookService_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *PushHookService) EXPECT() *PushHookService_Expecter { | |||
| 47 | + | return &PushHookService_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Check provides a mock function for the type PushHookService | |||
| 51 | + | func (_mock *PushHookService) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |||
| 52 | + | ret := _mock.Called(ctx, p, name, updates) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Check") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |||
| 60 | + | r0 = returnFunc(ctx, p, name, updates) | |||
| 61 | + | } else { | |||
| 62 | + | r0 = ret.Error(0) | |||
| 63 | + | } | |||
| 64 | + | return r0 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // PushHookService_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' | |||
| 68 | + | type PushHookService_Check_Call struct { | |||
| 69 | + | *mock.Call | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Check is a helper method to define mock.On call | |||
| 73 | + | // - ctx context.Context | |||
| 74 | + | // - p domain.Principal | |||
| 75 | + | // - name domain.RepoName | |||
| 76 | + | // - updates []domain.RefUpdate | |||
| 77 | + | func (_e *PushHookService_Expecter) Check(ctx any, p any, name any, updates any) *PushHookService_Check_Call { | |||
| 78 | + | return &PushHookService_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHookService_Check_Call { | |||
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 83 | + | var arg0 context.Context | |||
| 84 | + | if args[0] != nil { | |||
| 85 | + | arg0 = args[0].(context.Context) | |||
| 86 | + | } | |||
| 87 | + | var arg1 domain.Principal | |||
| 88 | + | if args[1] != nil { | |||
| 89 | + | arg1 = args[1].(domain.Principal) | |||
| 90 | + | } | |||
| 91 | + | var arg2 domain.RepoName | |||
| 92 | + | if args[2] != nil { | |||
| 93 | + | arg2 = args[2].(domain.RepoName) | |||
| 94 | + | } | |||
| 95 | + | var arg3 []domain.RefUpdate | |||
| 96 | + | if args[3] != nil { | |||
| 97 | + | arg3 = args[3].([]domain.RefUpdate) | |||
| 98 | + | } | |||
| 99 | + | run( | |||
| 100 | + | arg0, | |||
| 101 | + | arg1, | |||
| 102 | + | arg2, | |||
| 103 | + | arg3, | |||
| 104 | + | ) | |||
| 105 | + | }) | |||
| 106 | + | return _c | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (_c *PushHookService_Check_Call) Return(err error) *PushHookService_Check_Call { | |||
| 110 | + | _c.Call.Return(err) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (_c *PushHookService_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHookService_Check_Call { | |||
| 115 | + | _c.Call.Return(run) | |||
| 116 | + | return _c | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Record provides a mock function for the type PushHookService | |||
| 120 | + | func (_mock *PushHookService) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |||
| 121 | + | ret := _mock.Called(ctx, p, name, updates) | |||
| 122 | + | ||||
| 123 | + | if len(ret) == 0 { | |||
| 124 | + | panic("no return value specified for Record") | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | var r0 error | |||
| 128 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |||
| 129 | + | r0 = returnFunc(ctx, p, name, updates) | |||
| 130 | + | } else { | |||
| 131 | + | r0 = ret.Error(0) | |||
| 132 | + | } | |||
| 133 | + | return r0 | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // PushHookService_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record' | |||
| 137 | + | type PushHookService_Record_Call struct { | |||
| 138 | + | *mock.Call | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Record is a helper method to define mock.On call | |||
| 142 | + | // - ctx context.Context | |||
| 143 | + | // - p domain.Principal | |||
| 144 | + | // - name domain.RepoName | |||
| 145 | + | // - updates []domain.RefUpdate | |||
| 146 | + | func (_e *PushHookService_Expecter) Record(ctx any, p any, name any, updates any) *PushHookService_Record_Call { | |||
| 147 | + | return &PushHookService_Record_Call{Call: _e.mock.On("Record", ctx, p, name, updates)} | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (_c *PushHookService_Record_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHookService_Record_Call { | |||
| 151 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 152 | + | var arg0 context.Context | |||
| 153 | + | if args[0] != nil { | |||
| 154 | + | arg0 = args[0].(context.Context) | |||
| 155 | + | } | |||
| 156 | + | var arg1 domain.Principal | |||
| 157 | + | if args[1] != nil { | |||
| 158 | + | arg1 = args[1].(domain.Principal) | |||
| 159 | + | } | |||
| 160 | + | var arg2 domain.RepoName | |||
| 161 | + | if args[2] != nil { | |||
| 162 | + | arg2 = args[2].(domain.RepoName) | |||
| 163 | + | } | |||
| 164 | + | var arg3 []domain.RefUpdate | |||
| 165 | + | if args[3] != nil { | |||
| 166 | + | arg3 = args[3].([]domain.RefUpdate) | |||
| 167 | + | } | |||
| 168 | + | run( | |||
| 169 | + | arg0, | |||
| 170 | + | arg1, | |||
| 171 | + | arg2, | |||
| 172 | + | arg3, | |||
| 173 | + | ) | |||
| 174 | + | }) | |||
| 175 | + | return _c | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (_c *PushHookService_Record_Call) Return(err error) *PushHookService_Record_Call { | |||
| 179 | + | _c.Call.Return(err) | |||
| 180 | + | return _c | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (_c *PushHookService_Record_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHookService_Record_Call { | |||
| 184 | + | _c.Call.Return(run) | |||
| 185 | + | return _c | |||
| 186 | + | } | |||
A internal/ports/mocks/PushService.go +117 -0
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewPushService creates a new instance of PushService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewPushService(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *PushService { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &PushService{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // PushService is an autogenerated mock type for the PushService type | |||
| 38 | + | type PushService struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type PushService_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *PushService) EXPECT() *PushService_Expecter { | |||
| 47 | + | return &PushService_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Open provides a mock function for the type PushService | |||
| 51 | + | func (_mock *PushService) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error { | |||
| 52 | + | ret := _mock.Called(ctx, p, name, create) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Open") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok { | |||
| 60 | + | r0 = returnFunc(ctx, p, name, create) | |||
| 61 | + | } else { | |||
| 62 | + | r0 = ret.Error(0) | |||
| 63 | + | } | |||
| 64 | + | return r0 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // PushService_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |||
| 68 | + | type PushService_Open_Call struct { | |||
| 69 | + | *mock.Call | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Open is a helper method to define mock.On call | |||
| 73 | + | // - ctx context.Context | |||
| 74 | + | // - p domain.Principal | |||
| 75 | + | // - name domain.RepoName | |||
| 76 | + | // - create bool | |||
| 77 | + | func (_e *PushService_Expecter) Open(ctx any, p any, name any, create any) *PushService_Open_Call { | |||
| 78 | + | return &PushService_Open_Call{Call: _e.mock.On("Open", ctx, p, name, create)} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (_c *PushService_Open_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool)) *PushService_Open_Call { | |||
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 83 | + | var arg0 context.Context | |||
| 84 | + | if args[0] != nil { | |||
| 85 | + | arg0 = args[0].(context.Context) | |||
| 86 | + | } | |||
| 87 | + | var arg1 domain.Principal | |||
| 88 | + | if args[1] != nil { | |||
| 89 | + | arg1 = args[1].(domain.Principal) | |||
| 90 | + | } | |||
| 91 | + | var arg2 domain.RepoName | |||
| 92 | + | if args[2] != nil { | |||
| 93 | + | arg2 = args[2].(domain.RepoName) | |||
| 94 | + | } | |||
| 95 | + | var arg3 bool | |||
| 96 | + | if args[3] != nil { | |||
| 97 | + | arg3 = args[3].(bool) | |||
| 98 | + | } | |||
| 99 | + | run( | |||
| 100 | + | arg0, | |||
| 101 | + | arg1, | |||
| 102 | + | arg2, | |||
| 103 | + | arg3, | |||
| 104 | + | ) | |||
| 105 | + | }) | |||
| 106 | + | return _c | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (_c *PushService_Open_Call) Return(err error) *PushService_Open_Call { | |||
| 110 | + | _c.Call.Return(err) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (_c *PushService_Open_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error) *PushService_Open_Call { | |||
| 115 | + | _c.Call.Return(run) | |||
| 116 | + | return _c | |||
| 117 | + | } | |||
A internal/ports/mocks/RepoLookup.go +120 -0
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewRepoLookup creates a new instance of RepoLookup. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewRepoLookup(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *RepoLookup { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &RepoLookup{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // RepoLookup is an autogenerated mock type for the RepoLookup type | |||
| 38 | + | type RepoLookup struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type RepoLookup_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *RepoLookup) EXPECT() *RepoLookup_Expecter { | |||
| 47 | + | return &RepoLookup_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Get provides a mock function for the type RepoLookup | |||
| 51 | + | func (_mock *RepoLookup) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 52 | + | ret := _mock.Called(ctx, p, name) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Get") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 domain.Repo | |||
| 59 | + | var r1 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok { | |||
| 61 | + | return returnFunc(ctx, p, name) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok { | |||
| 64 | + | r0 = returnFunc(ctx, p, name) | |||
| 65 | + | } else { | |||
| 66 | + | r0 = ret.Get(0).(domain.Repo) | |||
| 67 | + | } | |||
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |||
| 69 | + | r1 = returnFunc(ctx, p, name) | |||
| 70 | + | } else { | |||
| 71 | + | r1 = ret.Error(1) | |||
| 72 | + | } | |||
| 73 | + | return r0, r1 | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // RepoLookup_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |||
| 77 | + | type RepoLookup_Get_Call struct { | |||
| 78 | + | *mock.Call | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Get is a helper method to define mock.On call | |||
| 82 | + | // - ctx context.Context | |||
| 83 | + | // - p domain.Principal | |||
| 84 | + | // - name domain.RepoName | |||
| 85 | + | func (_e *RepoLookup_Expecter) Get(ctx any, p any, name any) *RepoLookup_Get_Call { | |||
| 86 | + | return &RepoLookup_Get_Call{Call: _e.mock.On("Get", ctx, p, name)} | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (_c *RepoLookup_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *RepoLookup_Get_Call { | |||
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 91 | + | var arg0 context.Context | |||
| 92 | + | if args[0] != nil { | |||
| 93 | + | arg0 = args[0].(context.Context) | |||
| 94 | + | } | |||
| 95 | + | var arg1 domain.Principal | |||
| 96 | + | if args[1] != nil { | |||
| 97 | + | arg1 = args[1].(domain.Principal) | |||
| 98 | + | } | |||
| 99 | + | var arg2 domain.RepoName | |||
| 100 | + | if args[2] != nil { | |||
| 101 | + | arg2 = args[2].(domain.RepoName) | |||
| 102 | + | } | |||
| 103 | + | run( | |||
| 104 | + | arg0, | |||
| 105 | + | arg1, | |||
| 106 | + | arg2, | |||
| 107 | + | ) | |||
| 108 | + | }) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (_c *RepoLookup_Get_Call) Return(repo domain.Repo, err error) *RepoLookup_Get_Call { | |||
| 113 | + | _c.Call.Return(repo, err) | |||
| 114 | + | return _c | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (_c *RepoLookup_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *RepoLookup_Get_Call { | |||
| 118 | + | _c.Call.Return(run) | |||
| 119 | + | return _c | |||
| 120 | + | } | |||
A internal/ports/mocks/RepoService.go +478 -0
| @@ -0,0 +1,478 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewRepoService creates a new instance of RepoService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewRepoService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *RepoService { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &RepoService{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // RepoService is an autogenerated mock type for the RepoService type | |||
| 39 | + | type RepoService struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type RepoService_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *RepoService) EXPECT() *RepoService_Expecter { | |||
| 48 | + | return &RepoService_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Create provides a mock function for the type RepoService | |||
| 52 | + | func (_mock *RepoService) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { | |||
| 53 | + | ret := _mock.Called(ctx, p, params) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for Create") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 domain.Repo | |||
| 60 | + | var r1 error | |||
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateRepoParams) (domain.Repo, error)); ok { | |||
| 62 | + | return returnFunc(ctx, p, params) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateRepoParams) domain.Repo); ok { | |||
| 65 | + | r0 = returnFunc(ctx, p, params) | |||
| 66 | + | } else { | |||
| 67 | + | r0 = ret.Get(0).(domain.Repo) | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, ports.CreateRepoParams) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx, p, params) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // RepoService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |||
| 78 | + | type RepoService_Create_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Create is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | // - p domain.Principal | |||
| 85 | + | // - params ports.CreateRepoParams | |||
| 86 | + | func (_e *RepoService_Expecter) Create(ctx any, p any, params any) *RepoService_Create_Call { | |||
| 87 | + | return &RepoService_Create_Call{Call: _e.mock.On("Create", ctx, p, params)} | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (_c *RepoService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.CreateRepoParams)) *RepoService_Create_Call { | |||
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 92 | + | var arg0 context.Context | |||
| 93 | + | if args[0] != nil { | |||
| 94 | + | arg0 = args[0].(context.Context) | |||
| 95 | + | } | |||
| 96 | + | var arg1 domain.Principal | |||
| 97 | + | if args[1] != nil { | |||
| 98 | + | arg1 = args[1].(domain.Principal) | |||
| 99 | + | } | |||
| 100 | + | var arg2 ports.CreateRepoParams | |||
| 101 | + | if args[2] != nil { | |||
| 102 | + | arg2 = args[2].(ports.CreateRepoParams) | |||
| 103 | + | } | |||
| 104 | + | run( | |||
| 105 | + | arg0, | |||
| 106 | + | arg1, | |||
| 107 | + | arg2, | |||
| 108 | + | ) | |||
| 109 | + | }) | |||
| 110 | + | return _c | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (_c *RepoService_Create_Call) Return(repo domain.Repo, err error) *RepoService_Create_Call { | |||
| 114 | + | _c.Call.Return(repo, err) | |||
| 115 | + | return _c | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (_c *RepoService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error)) *RepoService_Create_Call { | |||
| 119 | + | _c.Call.Return(run) | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // Delete provides a mock function for the type RepoService | |||
| 124 | + | func (_mock *RepoService) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error { | |||
| 125 | + | ret := _mock.Called(ctx, p, name) | |||
| 126 | + | ||||
| 127 | + | if len(ret) == 0 { | |||
| 128 | + | panic("no return value specified for Delete") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | var r0 error | |||
| 132 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |||
| 133 | + | r0 = returnFunc(ctx, p, name) | |||
| 134 | + | } else { | |||
| 135 | + | r0 = ret.Error(0) | |||
| 136 | + | } | |||
| 137 | + | return r0 | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // RepoService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 141 | + | type RepoService_Delete_Call struct { | |||
| 142 | + | *mock.Call | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // Delete is a helper method to define mock.On call | |||
| 146 | + | // - ctx context.Context | |||
| 147 | + | // - p domain.Principal | |||
| 148 | + | // - name domain.RepoName | |||
| 149 | + | func (_e *RepoService_Expecter) Delete(ctx any, p any, name any) *RepoService_Delete_Call { | |||
| 150 | + | return &RepoService_Delete_Call{Call: _e.mock.On("Delete", ctx, p, name)} | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (_c *RepoService_Delete_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *RepoService_Delete_Call { | |||
| 154 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 155 | + | var arg0 context.Context | |||
| 156 | + | if args[0] != nil { | |||
| 157 | + | arg0 = args[0].(context.Context) | |||
| 158 | + | } | |||
| 159 | + | var arg1 domain.Principal | |||
| 160 | + | if args[1] != nil { | |||
| 161 | + | arg1 = args[1].(domain.Principal) | |||
| 162 | + | } | |||
| 163 | + | var arg2 domain.RepoName | |||
| 164 | + | if args[2] != nil { | |||
| 165 | + | arg2 = args[2].(domain.RepoName) | |||
| 166 | + | } | |||
| 167 | + | run( | |||
| 168 | + | arg0, | |||
| 169 | + | arg1, | |||
| 170 | + | arg2, | |||
| 171 | + | ) | |||
| 172 | + | }) | |||
| 173 | + | return _c | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (_c *RepoService_Delete_Call) Return(err error) *RepoService_Delete_Call { | |||
| 177 | + | _c.Call.Return(err) | |||
| 178 | + | return _c | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (_c *RepoService_Delete_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) error) *RepoService_Delete_Call { | |||
| 182 | + | _c.Call.Return(run) | |||
| 183 | + | return _c | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Get provides a mock function for the type RepoService | |||
| 187 | + | func (_mock *RepoService) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 188 | + | ret := _mock.Called(ctx, p, name) | |||
| 189 | + | ||||
| 190 | + | if len(ret) == 0 { | |||
| 191 | + | panic("no return value specified for Get") | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | var r0 domain.Repo | |||
| 195 | + | var r1 error | |||
| 196 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok { | |||
| 197 | + | return returnFunc(ctx, p, name) | |||
| 198 | + | } | |||
| 199 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok { | |||
| 200 | + | r0 = returnFunc(ctx, p, name) | |||
| 201 | + | } else { | |||
| 202 | + | r0 = ret.Get(0).(domain.Repo) | |||
| 203 | + | } | |||
| 204 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |||
| 205 | + | r1 = returnFunc(ctx, p, name) | |||
| 206 | + | } else { | |||
| 207 | + | r1 = ret.Error(1) | |||
| 208 | + | } | |||
| 209 | + | return r0, r1 | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // RepoService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |||
| 213 | + | type RepoService_Get_Call struct { | |||
| 214 | + | *mock.Call | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // Get is a helper method to define mock.On call | |||
| 218 | + | // - ctx context.Context | |||
| 219 | + | // - p domain.Principal | |||
| 220 | + | // - name domain.RepoName | |||
| 221 | + | func (_e *RepoService_Expecter) Get(ctx any, p any, name any) *RepoService_Get_Call { | |||
| 222 | + | return &RepoService_Get_Call{Call: _e.mock.On("Get", ctx, p, name)} | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func (_c *RepoService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *RepoService_Get_Call { | |||
| 226 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 227 | + | var arg0 context.Context | |||
| 228 | + | if args[0] != nil { | |||
| 229 | + | arg0 = args[0].(context.Context) | |||
| 230 | + | } | |||
| 231 | + | var arg1 domain.Principal | |||
| 232 | + | if args[1] != nil { | |||
| 233 | + | arg1 = args[1].(domain.Principal) | |||
| 234 | + | } | |||
| 235 | + | var arg2 domain.RepoName | |||
| 236 | + | if args[2] != nil { | |||
| 237 | + | arg2 = args[2].(domain.RepoName) | |||
| 238 | + | } | |||
| 239 | + | run( | |||
| 240 | + | arg0, | |||
| 241 | + | arg1, | |||
| 242 | + | arg2, | |||
| 243 | + | ) | |||
| 244 | + | }) | |||
| 245 | + | return _c | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (_c *RepoService_Get_Call) Return(repo domain.Repo, err error) *RepoService_Get_Call { | |||
| 249 | + | _c.Call.Return(repo, err) | |||
| 250 | + | return _c | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (_c *RepoService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *RepoService_Get_Call { | |||
| 254 | + | _c.Call.Return(run) | |||
| 255 | + | return _c | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // ListPage provides a mock function for the type RepoService | |||
| 259 | + | func (_mock *RepoService) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.RepoPage, error) { | |||
| 260 | + | ret := _mock.Called(ctx, p, size, token) | |||
| 261 | + | ||||
| 262 | + | if len(ret) == 0 { | |||
| 263 | + | panic("no return value specified for ListPage") | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | var r0 ports.RepoPage | |||
| 267 | + | var r1 error | |||
| 268 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) (ports.RepoPage, error)); ok { | |||
| 269 | + | return returnFunc(ctx, p, size, token) | |||
| 270 | + | } | |||
| 271 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) ports.RepoPage); ok { | |||
| 272 | + | r0 = returnFunc(ctx, p, size, token) | |||
| 273 | + | } else { | |||
| 274 | + | r0 = ret.Get(0).(ports.RepoPage) | |||
| 275 | + | } | |||
| 276 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, int, string) error); ok { | |||
| 277 | + | r1 = returnFunc(ctx, p, size, token) | |||
| 278 | + | } else { | |||
| 279 | + | r1 = ret.Error(1) | |||
| 280 | + | } | |||
| 281 | + | return r0, r1 | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // RepoService_ListPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPage' | |||
| 285 | + | type RepoService_ListPage_Call struct { | |||
| 286 | + | *mock.Call | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // ListPage is a helper method to define mock.On call | |||
| 290 | + | // - ctx context.Context | |||
| 291 | + | // - p domain.Principal | |||
| 292 | + | // - size int | |||
| 293 | + | // - token string | |||
| 294 | + | func (_e *RepoService_Expecter) ListPage(ctx any, p any, size any, token any) *RepoService_ListPage_Call { | |||
| 295 | + | return &RepoService_ListPage_Call{Call: _e.mock.On("ListPage", ctx, p, size, token)} | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (_c *RepoService_ListPage_Call) Run(run func(ctx context.Context, p domain.Principal, size int, token string)) *RepoService_ListPage_Call { | |||
| 299 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 300 | + | var arg0 context.Context | |||
| 301 | + | if args[0] != nil { | |||
| 302 | + | arg0 = args[0].(context.Context) | |||
| 303 | + | } | |||
| 304 | + | var arg1 domain.Principal | |||
| 305 | + | if args[1] != nil { | |||
| 306 | + | arg1 = args[1].(domain.Principal) | |||
| 307 | + | } | |||
| 308 | + | var arg2 int | |||
| 309 | + | if args[2] != nil { | |||
| 310 | + | arg2 = args[2].(int) | |||
| 311 | + | } | |||
| 312 | + | var arg3 string | |||
| 313 | + | if args[3] != nil { | |||
| 314 | + | arg3 = args[3].(string) | |||
| 315 | + | } | |||
| 316 | + | run( | |||
| 317 | + | arg0, | |||
| 318 | + | arg1, | |||
| 319 | + | arg2, | |||
| 320 | + | arg3, | |||
| 321 | + | ) | |||
| 322 | + | }) | |||
| 323 | + | return _c | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (_c *RepoService_ListPage_Call) Return(repoPage ports.RepoPage, err error) *RepoService_ListPage_Call { | |||
| 327 | + | _c.Call.Return(repoPage, err) | |||
| 328 | + | return _c | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func (_c *RepoService_ListPage_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, size int, token string) (ports.RepoPage, error)) *RepoService_ListPage_Call { | |||
| 332 | + | _c.Call.Return(run) | |||
| 333 | + | return _c | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // Update provides a mock function for the type RepoService | |||
| 337 | + | func (_mock *RepoService) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) { | |||
| 338 | + | ret := _mock.Called(ctx, p, name, params) | |||
| 339 | + | ||||
| 340 | + | if len(ret) == 0 { | |||
| 341 | + | panic("no return value specified for Update") | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | var r0 domain.Repo | |||
| 345 | + | var r1 error | |||
| 346 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UpdateRepoParams) (domain.Repo, error)); ok { | |||
| 347 | + | return returnFunc(ctx, p, name, params) | |||
| 348 | + | } | |||
| 349 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UpdateRepoParams) domain.Repo); ok { | |||
| 350 | + | r0 = returnFunc(ctx, p, name, params) | |||
| 351 | + | } else { | |||
| 352 | + | r0 = ret.Get(0).(domain.Repo) | |||
| 353 | + | } | |||
| 354 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.UpdateRepoParams) error); ok { | |||
| 355 | + | r1 = returnFunc(ctx, p, name, params) | |||
| 356 | + | } else { | |||
| 357 | + | r1 = ret.Error(1) | |||
| 358 | + | } | |||
| 359 | + | return r0, r1 | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // RepoService_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' | |||
| 363 | + | type RepoService_Update_Call struct { | |||
| 364 | + | *mock.Call | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // Update is a helper method to define mock.On call | |||
| 368 | + | // - ctx context.Context | |||
| 369 | + | // - p domain.Principal | |||
| 370 | + | // - name domain.RepoName | |||
| 371 | + | // - params ports.UpdateRepoParams | |||
| 372 | + | func (_e *RepoService_Expecter) Update(ctx any, p any, name any, params any) *RepoService_Update_Call { | |||
| 373 | + | return &RepoService_Update_Call{Call: _e.mock.On("Update", ctx, p, name, params)} | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func (_c *RepoService_Update_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UpdateRepoParams)) *RepoService_Update_Call { | |||
| 377 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 378 | + | var arg0 context.Context | |||
| 379 | + | if args[0] != nil { | |||
| 380 | + | arg0 = args[0].(context.Context) | |||
| 381 | + | } | |||
| 382 | + | var arg1 domain.Principal | |||
| 383 | + | if args[1] != nil { | |||
| 384 | + | arg1 = args[1].(domain.Principal) | |||
| 385 | + | } | |||
| 386 | + | var arg2 domain.RepoName | |||
| 387 | + | if args[2] != nil { | |||
| 388 | + | arg2 = args[2].(domain.RepoName) | |||
| 389 | + | } | |||
| 390 | + | var arg3 ports.UpdateRepoParams | |||
| 391 | + | if args[3] != nil { | |||
| 392 | + | arg3 = args[3].(ports.UpdateRepoParams) | |||
| 393 | + | } | |||
| 394 | + | run( | |||
| 395 | + | arg0, | |||
| 396 | + | arg1, | |||
| 397 | + | arg2, | |||
| 398 | + | arg3, | |||
| 399 | + | ) | |||
| 400 | + | }) | |||
| 401 | + | return _c | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | func (_c *RepoService_Update_Call) Return(repo domain.Repo, err error) *RepoService_Update_Call { | |||
| 405 | + | _c.Call.Return(repo, err) | |||
| 406 | + | return _c | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func (_c *RepoService_Update_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error)) *RepoService_Update_Call { | |||
| 410 | + | _c.Call.Return(run) | |||
| 411 | + | return _c | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | // View provides a mock function for the type RepoService | |||
| 415 | + | func (_mock *RepoService) View(ctx context.Context, repo domain.Repo) (ports.RepoView, error) { | |||
| 416 | + | ret := _mock.Called(ctx, repo) | |||
| 417 | + | ||||
| 418 | + | if len(ret) == 0 { | |||
| 419 | + | panic("no return value specified for View") | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | var r0 ports.RepoView | |||
| 423 | + | var r1 error | |||
| 424 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) (ports.RepoView, error)); ok { | |||
| 425 | + | return returnFunc(ctx, repo) | |||
| 426 | + | } | |||
| 427 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) ports.RepoView); ok { | |||
| 428 | + | r0 = returnFunc(ctx, repo) | |||
| 429 | + | } else { | |||
| 430 | + | r0 = ret.Get(0).(ports.RepoView) | |||
| 431 | + | } | |||
| 432 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Repo) error); ok { | |||
| 433 | + | r1 = returnFunc(ctx, repo) | |||
| 434 | + | } else { | |||
| 435 | + | r1 = ret.Error(1) | |||
| 436 | + | } | |||
| 437 | + | return r0, r1 | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | // RepoService_View_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'View' | |||
| 441 | + | type RepoService_View_Call struct { | |||
| 442 | + | *mock.Call | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | // View is a helper method to define mock.On call | |||
| 446 | + | // - ctx context.Context | |||
| 447 | + | // - repo domain.Repo | |||
| 448 | + | func (_e *RepoService_Expecter) View(ctx any, repo any) *RepoService_View_Call { | |||
| 449 | + | return &RepoService_View_Call{Call: _e.mock.On("View", ctx, repo)} | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func (_c *RepoService_View_Call) Run(run func(ctx context.Context, repo domain.Repo)) *RepoService_View_Call { | |||
| 453 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 454 | + | var arg0 context.Context | |||
| 455 | + | if args[0] != nil { | |||
| 456 | + | arg0 = args[0].(context.Context) | |||
| 457 | + | } | |||
| 458 | + | var arg1 domain.Repo | |||
| 459 | + | if args[1] != nil { | |||
| 460 | + | arg1 = args[1].(domain.Repo) | |||
| 461 | + | } | |||
| 462 | + | run( | |||
| 463 | + | arg0, | |||
| 464 | + | arg1, | |||
| 465 | + | ) | |||
| 466 | + | }) | |||
| 467 | + | return _c | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func (_c *RepoService_View_Call) Return(repoView ports.RepoView, err error) *RepoService_View_Call { | |||
| 471 | + | _c.Call.Return(repoView, err) | |||
| 472 | + | return _c | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func (_c *RepoService_View_Call) RunAndReturn(run func(ctx context.Context, repo domain.Repo) (ports.RepoView, error)) *RepoService_View_Call { | |||
| 476 | + | _c.Call.Return(run) | |||
| 477 | + | return _c | |||
| 478 | + | } | |||
A internal/ports/mocks/Revalidator.go +105 -0
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewRevalidator creates a new instance of Revalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewRevalidator(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *Revalidator { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &Revalidator{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Revalidator is an autogenerated mock type for the Revalidator type | |||
| 38 | + | type Revalidator struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type Revalidator_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *Revalidator) EXPECT() *Revalidator_Expecter { | |||
| 47 | + | return &Revalidator_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Revalidate provides a mock function for the type Revalidator | |||
| 51 | + | func (_mock *Revalidator) Revalidate(ctx context.Context, p domain.Principal) error { | |||
| 52 | + | ret := _mock.Called(ctx, p) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Revalidate") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok { | |||
| 60 | + | r0 = returnFunc(ctx, p) | |||
| 61 | + | } else { | |||
| 62 | + | r0 = ret.Error(0) | |||
| 63 | + | } | |||
| 64 | + | return r0 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Revalidator_Revalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revalidate' | |||
| 68 | + | type Revalidator_Revalidate_Call struct { | |||
| 69 | + | *mock.Call | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Revalidate is a helper method to define mock.On call | |||
| 73 | + | // - ctx context.Context | |||
| 74 | + | // - p domain.Principal | |||
| 75 | + | func (_e *Revalidator_Expecter) Revalidate(ctx any, p any) *Revalidator_Revalidate_Call { | |||
| 76 | + | return &Revalidator_Revalidate_Call{Call: _e.mock.On("Revalidate", ctx, p)} | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (_c *Revalidator_Revalidate_Call) Run(run func(ctx context.Context, p domain.Principal)) *Revalidator_Revalidate_Call { | |||
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 81 | + | var arg0 context.Context | |||
| 82 | + | if args[0] != nil { | |||
| 83 | + | arg0 = args[0].(context.Context) | |||
| 84 | + | } | |||
| 85 | + | var arg1 domain.Principal | |||
| 86 | + | if args[1] != nil { | |||
| 87 | + | arg1 = args[1].(domain.Principal) | |||
| 88 | + | } | |||
| 89 | + | run( | |||
| 90 | + | arg0, | |||
| 91 | + | arg1, | |||
| 92 | + | ) | |||
| 93 | + | }) | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (_c *Revalidator_Revalidate_Call) Return(err error) *Revalidator_Revalidate_Call { | |||
| 98 | + | _c.Call.Return(err) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *Revalidator_Revalidate_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *Revalidator_Revalidate_Call { | |||
| 103 | + | _c.Call.Return(run) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
A internal/ports/mocks/SessionService.go +172 -0
| @@ -0,0 +1,172 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewSessionService creates a new instance of SessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewSessionService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *SessionService { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &SessionService{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // SessionService is an autogenerated mock type for the SessionService type | |||
| 39 | + | type SessionService struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type SessionService_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *SessionService) EXPECT() *SessionService_Expecter { | |||
| 48 | + | return &SessionService_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // CreateLoginLink provides a mock function for the type SessionService | |||
| 52 | + | func (_mock *SessionService) CreateLoginLink(ctx context.Context, p domain.Principal) (ports.LoginLink, error) { | |||
| 53 | + | ret := _mock.Called(ctx, p) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for CreateLoginLink") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 ports.LoginLink | |||
| 60 | + | var r1 error | |||
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (ports.LoginLink, error)); ok { | |||
| 62 | + | return returnFunc(ctx, p) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ports.LoginLink); ok { | |||
| 65 | + | r0 = returnFunc(ctx, p) | |||
| 66 | + | } else { | |||
| 67 | + | r0 = ret.Get(0).(ports.LoginLink) | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx, p) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // SessionService_CreateLoginLink_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLoginLink' | |||
| 78 | + | type SessionService_CreateLoginLink_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // CreateLoginLink is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | // - p domain.Principal | |||
| 85 | + | func (_e *SessionService_Expecter) CreateLoginLink(ctx any, p any) *SessionService_CreateLoginLink_Call { | |||
| 86 | + | return &SessionService_CreateLoginLink_Call{Call: _e.mock.On("CreateLoginLink", ctx, p)} | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (_c *SessionService_CreateLoginLink_Call) Run(run func(ctx context.Context, p domain.Principal)) *SessionService_CreateLoginLink_Call { | |||
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 91 | + | var arg0 context.Context | |||
| 92 | + | if args[0] != nil { | |||
| 93 | + | arg0 = args[0].(context.Context) | |||
| 94 | + | } | |||
| 95 | + | var arg1 domain.Principal | |||
| 96 | + | if args[1] != nil { | |||
| 97 | + | arg1 = args[1].(domain.Principal) | |||
| 98 | + | } | |||
| 99 | + | run( | |||
| 100 | + | arg0, | |||
| 101 | + | arg1, | |||
| 102 | + | ) | |||
| 103 | + | }) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (_c *SessionService_CreateLoginLink_Call) Return(loginLink ports.LoginLink, err error) *SessionService_CreateLoginLink_Call { | |||
| 108 | + | _c.Call.Return(loginLink, err) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (_c *SessionService_CreateLoginLink_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (ports.LoginLink, error)) *SessionService_CreateLoginLink_Call { | |||
| 113 | + | _c.Call.Return(run) | |||
| 114 | + | return _c | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // SignOutEverywhere provides a mock function for the type SessionService | |||
| 118 | + | func (_mock *SessionService) SignOutEverywhere(ctx context.Context, p domain.Principal) error { | |||
| 119 | + | ret := _mock.Called(ctx, p) | |||
| 120 | + | ||||
| 121 | + | if len(ret) == 0 { | |||
| 122 | + | panic("no return value specified for SignOutEverywhere") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | var r0 error | |||
| 126 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok { | |||
| 127 | + | r0 = returnFunc(ctx, p) | |||
| 128 | + | } else { | |||
| 129 | + | r0 = ret.Error(0) | |||
| 130 | + | } | |||
| 131 | + | return r0 | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // SessionService_SignOutEverywhere_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignOutEverywhere' | |||
| 135 | + | type SessionService_SignOutEverywhere_Call struct { | |||
| 136 | + | *mock.Call | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // SignOutEverywhere is a helper method to define mock.On call | |||
| 140 | + | // - ctx context.Context | |||
| 141 | + | // - p domain.Principal | |||
| 142 | + | func (_e *SessionService_Expecter) SignOutEverywhere(ctx any, p any) *SessionService_SignOutEverywhere_Call { | |||
| 143 | + | return &SessionService_SignOutEverywhere_Call{Call: _e.mock.On("SignOutEverywhere", ctx, p)} | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func (_c *SessionService_SignOutEverywhere_Call) Run(run func(ctx context.Context, p domain.Principal)) *SessionService_SignOutEverywhere_Call { | |||
| 147 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 148 | + | var arg0 context.Context | |||
| 149 | + | if args[0] != nil { | |||
| 150 | + | arg0 = args[0].(context.Context) | |||
| 151 | + | } | |||
| 152 | + | var arg1 domain.Principal | |||
| 153 | + | if args[1] != nil { | |||
| 154 | + | arg1 = args[1].(domain.Principal) | |||
| 155 | + | } | |||
| 156 | + | run( | |||
| 157 | + | arg0, | |||
| 158 | + | arg1, | |||
| 159 | + | ) | |||
| 160 | + | }) | |||
| 161 | + | return _c | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func (_c *SessionService_SignOutEverywhere_Call) Return(err error) *SessionService_SignOutEverywhere_Call { | |||
| 165 | + | _c.Call.Return(err) | |||
| 166 | + | return _c | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (_c *SessionService_SignOutEverywhere_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *SessionService_SignOutEverywhere_Call { | |||
| 170 | + | _c.Call.Return(run) | |||
| 171 | + | return _c | |||
| 172 | + | } | |||
A internal/ports/mocks/SigningKeyService.go +90 -0
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "bnema.dev/ermite/internal/domain" | |||
| 9 | + | mock "github.com/stretchr/testify/mock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NewSigningKeyService creates a new instance of SigningKeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | + | // The first argument is typically a *testing.T value. | |||
| 14 | + | func NewSigningKeyService(t interface { | |||
| 15 | + | mock.TestingT | |||
| 16 | + | Cleanup(func()) | |||
| 17 | + | }) *SigningKeyService { | |||
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 19 | + | helper.Helper() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | mock := &SigningKeyService{} | |||
| 23 | + | mock.Mock.Test(t) | |||
| 24 | + | ||||
| 25 | + | t.Cleanup(func() { | |||
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 27 | + | helper.Helper() | |||
| 28 | + | } | |||
| 29 | + | mock.AssertExpectations(t) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | return mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // SigningKeyService is an autogenerated mock type for the SigningKeyService type | |||
| 36 | + | type SigningKeyService struct { | |||
| 37 | + | mock.Mock | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type SigningKeyService_Expecter struct { | |||
| 41 | + | mock *mock.Mock | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (_m *SigningKeyService) EXPECT() *SigningKeyService_Expecter { | |||
| 45 | + | return &SigningKeyService_Expecter{mock: &_m.Mock} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Info provides a mock function for the type SigningKeyService | |||
| 49 | + | func (_mock *SigningKeyService) Info() domain.SigningKeyInfo { | |||
| 50 | + | ret := _mock.Called() | |||
| 51 | + | ||||
| 52 | + | if len(ret) == 0 { | |||
| 53 | + | panic("no return value specified for Info") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | var r0 domain.SigningKeyInfo | |||
| 57 | + | if returnFunc, ok := ret.Get(0).(func() domain.SigningKeyInfo); ok { | |||
| 58 | + | r0 = returnFunc() | |||
| 59 | + | } else { | |||
| 60 | + | r0 = ret.Get(0).(domain.SigningKeyInfo) | |||
| 61 | + | } | |||
| 62 | + | return r0 | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // SigningKeyService_Info_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Info' | |||
| 66 | + | type SigningKeyService_Info_Call struct { | |||
| 67 | + | *mock.Call | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Info is a helper method to define mock.On call | |||
| 71 | + | func (_e *SigningKeyService_Expecter) Info() *SigningKeyService_Info_Call { | |||
| 72 | + | return &SigningKeyService_Info_Call{Call: _e.mock.On("Info")} | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (_c *SigningKeyService_Info_Call) Run(run func()) *SigningKeyService_Info_Call { | |||
| 76 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 77 | + | run() | |||
| 78 | + | }) | |||
| 79 | + | return _c | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *SigningKeyService_Info_Call) Return(signingKeyInfo domain.SigningKeyInfo) *SigningKeyService_Info_Call { | |||
| 83 | + | _c.Call.Return(signingKeyInfo) | |||
| 84 | + | return _c | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (_c *SigningKeyService_Info_Call) RunAndReturn(run func() domain.SigningKeyInfo) *SigningKeyService_Info_Call { | |||
| 88 | + | _c.Call.Return(run) | |||
| 89 | + | return _c | |||
| 90 | + | } | |||
A internal/ports/mocks/TokenService.go +262 -0
| @@ -0,0 +1,262 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewTokenService creates a new instance of TokenService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewTokenService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *TokenService { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &TokenService{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // TokenService is an autogenerated mock type for the TokenService type | |||
| 39 | + | type TokenService struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type TokenService_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *TokenService) EXPECT() *TokenService_Expecter { | |||
| 48 | + | return &TokenService_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Create provides a mock function for the type TokenService | |||
| 52 | + | func (_mock *TokenService) Create(ctx context.Context, p domain.Principal, params ports.CreateTokenParams) (ports.CreatedToken, error) { | |||
| 53 | + | ret := _mock.Called(ctx, p, params) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for Create") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 ports.CreatedToken | |||
| 60 | + | var r1 error | |||
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateTokenParams) (ports.CreatedToken, error)); ok { | |||
| 62 | + | return returnFunc(ctx, p, params) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateTokenParams) ports.CreatedToken); ok { | |||
| 65 | + | r0 = returnFunc(ctx, p, params) | |||
| 66 | + | } else { | |||
| 67 | + | r0 = ret.Get(0).(ports.CreatedToken) | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, ports.CreateTokenParams) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx, p, params) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // TokenService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |||
| 78 | + | type TokenService_Create_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Create is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | // - p domain.Principal | |||
| 85 | + | // - params ports.CreateTokenParams | |||
| 86 | + | func (_e *TokenService_Expecter) Create(ctx any, p any, params any) *TokenService_Create_Call { | |||
| 87 | + | return &TokenService_Create_Call{Call: _e.mock.On("Create", ctx, p, params)} | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (_c *TokenService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.CreateTokenParams)) *TokenService_Create_Call { | |||
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 92 | + | var arg0 context.Context | |||
| 93 | + | if args[0] != nil { | |||
| 94 | + | arg0 = args[0].(context.Context) | |||
| 95 | + | } | |||
| 96 | + | var arg1 domain.Principal | |||
| 97 | + | if args[1] != nil { | |||
| 98 | + | arg1 = args[1].(domain.Principal) | |||
| 99 | + | } | |||
| 100 | + | var arg2 ports.CreateTokenParams | |||
| 101 | + | if args[2] != nil { | |||
| 102 | + | arg2 = args[2].(ports.CreateTokenParams) | |||
| 103 | + | } | |||
| 104 | + | run( | |||
| 105 | + | arg0, | |||
| 106 | + | arg1, | |||
| 107 | + | arg2, | |||
| 108 | + | ) | |||
| 109 | + | }) | |||
| 110 | + | return _c | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (_c *TokenService_Create_Call) Return(createdToken ports.CreatedToken, err error) *TokenService_Create_Call { | |||
| 114 | + | _c.Call.Return(createdToken, err) | |||
| 115 | + | return _c | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (_c *TokenService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.CreateTokenParams) (ports.CreatedToken, error)) *TokenService_Create_Call { | |||
| 119 | + | _c.Call.Return(run) | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // ListPage provides a mock function for the type TokenService | |||
| 124 | + | func (_mock *TokenService) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.TokenPage, error) { | |||
| 125 | + | ret := _mock.Called(ctx, p, size, token) | |||
| 126 | + | ||||
| 127 | + | if len(ret) == 0 { | |||
| 128 | + | panic("no return value specified for ListPage") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | var r0 ports.TokenPage | |||
| 132 | + | var r1 error | |||
| 133 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) (ports.TokenPage, error)); ok { | |||
| 134 | + | return returnFunc(ctx, p, size, token) | |||
| 135 | + | } | |||
| 136 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) ports.TokenPage); ok { | |||
| 137 | + | r0 = returnFunc(ctx, p, size, token) | |||
| 138 | + | } else { | |||
| 139 | + | r0 = ret.Get(0).(ports.TokenPage) | |||
| 140 | + | } | |||
| 141 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, int, string) error); ok { | |||
| 142 | + | r1 = returnFunc(ctx, p, size, token) | |||
| 143 | + | } else { | |||
| 144 | + | r1 = ret.Error(1) | |||
| 145 | + | } | |||
| 146 | + | return r0, r1 | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // TokenService_ListPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPage' | |||
| 150 | + | type TokenService_ListPage_Call struct { | |||
| 151 | + | *mock.Call | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // ListPage is a helper method to define mock.On call | |||
| 155 | + | // - ctx context.Context | |||
| 156 | + | // - p domain.Principal | |||
| 157 | + | // - size int | |||
| 158 | + | // - token string | |||
| 159 | + | func (_e *TokenService_Expecter) ListPage(ctx any, p any, size any, token any) *TokenService_ListPage_Call { | |||
| 160 | + | return &TokenService_ListPage_Call{Call: _e.mock.On("ListPage", ctx, p, size, token)} | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (_c *TokenService_ListPage_Call) Run(run func(ctx context.Context, p domain.Principal, size int, token string)) *TokenService_ListPage_Call { | |||
| 164 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 165 | + | var arg0 context.Context | |||
| 166 | + | if args[0] != nil { | |||
| 167 | + | arg0 = args[0].(context.Context) | |||
| 168 | + | } | |||
| 169 | + | var arg1 domain.Principal | |||
| 170 | + | if args[1] != nil { | |||
| 171 | + | arg1 = args[1].(domain.Principal) | |||
| 172 | + | } | |||
| 173 | + | var arg2 int | |||
| 174 | + | if args[2] != nil { | |||
| 175 | + | arg2 = args[2].(int) | |||
| 176 | + | } | |||
| 177 | + | var arg3 string | |||
| 178 | + | if args[3] != nil { | |||
| 179 | + | arg3 = args[3].(string) | |||
| 180 | + | } | |||
| 181 | + | run( | |||
| 182 | + | arg0, | |||
| 183 | + | arg1, | |||
| 184 | + | arg2, | |||
| 185 | + | arg3, | |||
| 186 | + | ) | |||
| 187 | + | }) | |||
| 188 | + | return _c | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func (_c *TokenService_ListPage_Call) Return(tokenPage ports.TokenPage, err error) *TokenService_ListPage_Call { | |||
| 192 | + | _c.Call.Return(tokenPage, err) | |||
| 193 | + | return _c | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (_c *TokenService_ListPage_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, size int, token string) (ports.TokenPage, error)) *TokenService_ListPage_Call { | |||
| 197 | + | _c.Call.Return(run) | |||
| 198 | + | return _c | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // Revoke provides a mock function for the type TokenService | |||
| 202 | + | func (_mock *TokenService) Revoke(ctx context.Context, p domain.Principal, id string) error { | |||
| 203 | + | ret := _mock.Called(ctx, p, id) | |||
| 204 | + | ||||
| 205 | + | if len(ret) == 0 { | |||
| 206 | + | panic("no return value specified for Revoke") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | var r0 error | |||
| 210 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) error); ok { | |||
| 211 | + | r0 = returnFunc(ctx, p, id) | |||
| 212 | + | } else { | |||
| 213 | + | r0 = ret.Error(0) | |||
| 214 | + | } | |||
| 215 | + | return r0 | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // TokenService_Revoke_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revoke' | |||
| 219 | + | type TokenService_Revoke_Call struct { | |||
| 220 | + | *mock.Call | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // Revoke is a helper method to define mock.On call | |||
| 224 | + | // - ctx context.Context | |||
| 225 | + | // - p domain.Principal | |||
| 226 | + | // - id string | |||
| 227 | + | func (_e *TokenService_Expecter) Revoke(ctx any, p any, id any) *TokenService_Revoke_Call { | |||
| 228 | + | return &TokenService_Revoke_Call{Call: _e.mock.On("Revoke", ctx, p, id)} | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (_c *TokenService_Revoke_Call) Run(run func(ctx context.Context, p domain.Principal, id string)) *TokenService_Revoke_Call { | |||
| 232 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 233 | + | var arg0 context.Context | |||
| 234 | + | if args[0] != nil { | |||
| 235 | + | arg0 = args[0].(context.Context) | |||
| 236 | + | } | |||
| 237 | + | var arg1 domain.Principal | |||
| 238 | + | if args[1] != nil { | |||
| 239 | + | arg1 = args[1].(domain.Principal) | |||
| 240 | + | } | |||
| 241 | + | var arg2 string | |||
| 242 | + | if args[2] != nil { | |||
| 243 | + | arg2 = args[2].(string) | |||
| 244 | + | } | |||
| 245 | + | run( | |||
| 246 | + | arg0, | |||
| 247 | + | arg1, | |||
| 248 | + | arg2, | |||
| 249 | + | ) | |||
| 250 | + | }) | |||
| 251 | + | return _c | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (_c *TokenService_Revoke_Call) Return(err error) *TokenService_Revoke_Call { | |||
| 255 | + | _c.Call.Return(err) | |||
| 256 | + | return _c | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func (_c *TokenService_Revoke_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id string) error) *TokenService_Revoke_Call { | |||
| 260 | + | _c.Call.Return(run) | |||
| 261 | + | return _c | |||
| 262 | + | } | |||
A internal/ports/mocks/WebSessionService.go +244 -0
| @@ -0,0 +1,244 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewWebSessionService creates a new instance of WebSessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewWebSessionService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *WebSessionService { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &WebSessionService{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // WebSessionService is an autogenerated mock type for the WebSessionService type | |||
| 39 | + | type WebSessionService struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type WebSessionService_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *WebSessionService) EXPECT() *WebSessionService_Expecter { | |||
| 48 | + | return &WebSessionService_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Login provides a mock function for the type WebSessionService | |||
| 52 | + | func (_mock *WebSessionService) Login(ctx context.Context, nonce string) (string, time.Duration, error) { | |||
| 53 | + | ret := _mock.Called(ctx, nonce) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for Login") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 string | |||
| 60 | + | var r1 time.Duration | |||
| 61 | + | var r2 error | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, time.Duration, error)); ok { | |||
| 63 | + | return returnFunc(ctx, nonce) | |||
| 64 | + | } | |||
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |||
| 66 | + | r0 = returnFunc(ctx, nonce) | |||
| 67 | + | } else { | |||
| 68 | + | r0 = ret.Get(0).(string) | |||
| 69 | + | } | |||
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) time.Duration); ok { | |||
| 71 | + | r1 = returnFunc(ctx, nonce) | |||
| 72 | + | } else { | |||
| 73 | + | r1 = ret.Get(1).(time.Duration) | |||
| 74 | + | } | |||
| 75 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |||
| 76 | + | r2 = returnFunc(ctx, nonce) | |||
| 77 | + | } else { | |||
| 78 | + | r2 = ret.Error(2) | |||
| 79 | + | } | |||
| 80 | + | return r0, r1, r2 | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // WebSessionService_Login_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Login' | |||
| 84 | + | type WebSessionService_Login_Call struct { | |||
| 85 | + | *mock.Call | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Login is a helper method to define mock.On call | |||
| 89 | + | // - ctx context.Context | |||
| 90 | + | // - nonce string | |||
| 91 | + | func (_e *WebSessionService_Expecter) Login(ctx any, nonce any) *WebSessionService_Login_Call { | |||
| 92 | + | return &WebSessionService_Login_Call{Call: _e.mock.On("Login", ctx, nonce)} | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (_c *WebSessionService_Login_Call) Run(run func(ctx context.Context, nonce string)) *WebSessionService_Login_Call { | |||
| 96 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 97 | + | var arg0 context.Context | |||
| 98 | + | if args[0] != nil { | |||
| 99 | + | arg0 = args[0].(context.Context) | |||
| 100 | + | } | |||
| 101 | + | var arg1 string | |||
| 102 | + | if args[1] != nil { | |||
| 103 | + | arg1 = args[1].(string) | |||
| 104 | + | } | |||
| 105 | + | run( | |||
| 106 | + | arg0, | |||
| 107 | + | arg1, | |||
| 108 | + | ) | |||
| 109 | + | }) | |||
| 110 | + | return _c | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (_c *WebSessionService_Login_Call) Return(token string, lifetime time.Duration, err error) *WebSessionService_Login_Call { | |||
| 114 | + | _c.Call.Return(token, lifetime, err) | |||
| 115 | + | return _c | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (_c *WebSessionService_Login_Call) RunAndReturn(run func(ctx context.Context, nonce string) (string, time.Duration, error)) *WebSessionService_Login_Call { | |||
| 119 | + | _c.Call.Return(run) | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // LoginLinkValid provides a mock function for the type WebSessionService | |||
| 124 | + | func (_mock *WebSessionService) LoginLinkValid(nonce string) bool { | |||
| 125 | + | ret := _mock.Called(nonce) | |||
| 126 | + | ||||
| 127 | + | if len(ret) == 0 { | |||
| 128 | + | panic("no return value specified for LoginLinkValid") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | var r0 bool | |||
| 132 | + | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | |||
| 133 | + | r0 = returnFunc(nonce) | |||
| 134 | + | } else { | |||
| 135 | + | r0 = ret.Get(0).(bool) | |||
| 136 | + | } | |||
| 137 | + | return r0 | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // WebSessionService_LoginLinkValid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoginLinkValid' | |||
| 141 | + | type WebSessionService_LoginLinkValid_Call struct { | |||
| 142 | + | *mock.Call | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // LoginLinkValid is a helper method to define mock.On call | |||
| 146 | + | // - nonce string | |||
| 147 | + | func (_e *WebSessionService_Expecter) LoginLinkValid(nonce any) *WebSessionService_LoginLinkValid_Call { | |||
| 148 | + | return &WebSessionService_LoginLinkValid_Call{Call: _e.mock.On("LoginLinkValid", nonce)} | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func (_c *WebSessionService_LoginLinkValid_Call) Run(run func(nonce string)) *WebSessionService_LoginLinkValid_Call { | |||
| 152 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 153 | + | var arg0 string | |||
| 154 | + | if args[0] != nil { | |||
| 155 | + | arg0 = args[0].(string) | |||
| 156 | + | } | |||
| 157 | + | run( | |||
| 158 | + | arg0, | |||
| 159 | + | ) | |||
| 160 | + | }) | |||
| 161 | + | return _c | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func (_c *WebSessionService_LoginLinkValid_Call) Return(b bool) *WebSessionService_LoginLinkValid_Call { | |||
| 165 | + | _c.Call.Return(b) | |||
| 166 | + | return _c | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (_c *WebSessionService_LoginLinkValid_Call) RunAndReturn(run func(nonce string) bool) *WebSessionService_LoginLinkValid_Call { | |||
| 170 | + | _c.Call.Return(run) | |||
| 171 | + | return _c | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // Verify provides a mock function for the type WebSessionService | |||
| 175 | + | func (_mock *WebSessionService) Verify(ctx context.Context, token string) (domain.Principal, bool, error) { | |||
| 176 | + | ret := _mock.Called(ctx, token) | |||
| 177 | + | ||||
| 178 | + | if len(ret) == 0 { | |||
| 179 | + | panic("no return value specified for Verify") | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | var r0 domain.Principal | |||
| 183 | + | var r1 bool | |||
| 184 | + | var r2 error | |||
| 185 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, bool, error)); ok { | |||
| 186 | + | return returnFunc(ctx, token) | |||
| 187 | + | } | |||
| 188 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok { | |||
| 189 | + | r0 = returnFunc(ctx, token) | |||
| 190 | + | } else { | |||
| 191 | + | r0 = ret.Get(0).(domain.Principal) | |||
| 192 | + | } | |||
| 193 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | |||
| 194 | + | r1 = returnFunc(ctx, token) | |||
| 195 | + | } else { | |||
| 196 | + | r1 = ret.Get(1).(bool) | |||
| 197 | + | } | |||
| 198 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |||
| 199 | + | r2 = returnFunc(ctx, token) | |||
| 200 | + | } else { | |||
| 201 | + | r2 = ret.Error(2) | |||
| 202 | + | } | |||
| 203 | + | return r0, r1, r2 | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // WebSessionService_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' | |||
| 207 | + | type WebSessionService_Verify_Call struct { | |||
| 208 | + | *mock.Call | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // Verify is a helper method to define mock.On call | |||
| 212 | + | // - ctx context.Context | |||
| 213 | + | // - token string | |||
| 214 | + | func (_e *WebSessionService_Expecter) Verify(ctx any, token any) *WebSessionService_Verify_Call { | |||
| 215 | + | return &WebSessionService_Verify_Call{Call: _e.mock.On("Verify", ctx, token)} | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (_c *WebSessionService_Verify_Call) Run(run func(ctx context.Context, token string)) *WebSessionService_Verify_Call { | |||
| 219 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 220 | + | var arg0 context.Context | |||
| 221 | + | if args[0] != nil { | |||
| 222 | + | arg0 = args[0].(context.Context) | |||
| 223 | + | } | |||
| 224 | + | var arg1 string | |||
| 225 | + | if args[1] != nil { | |||
| 226 | + | arg1 = args[1].(string) | |||
| 227 | + | } | |||
| 228 | + | run( | |||
| 229 | + | arg0, | |||
| 230 | + | arg1, | |||
| 231 | + | ) | |||
| 232 | + | }) | |||
| 233 | + | return _c | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (_c *WebSessionService_Verify_Call) Return(owner domain.Principal, ok bool, err error) *WebSessionService_Verify_Call { | |||
| 237 | + | _c.Call.Return(owner, ok, err) | |||
| 238 | + | return _c | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func (_c *WebSessionService_Verify_Call) RunAndReturn(run func(ctx context.Context, token string) (domain.Principal, bool, error)) *WebSessionService_Verify_Call { | |||
| 242 | + | _c.Call.Return(run) | |||
| 243 | + | return _c | |||
| 244 | + | } | |||
M internal/ports/ports.go +9 -2
| @@ -1,7 +1,14 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | // Package ports declares the interfaces the use cases need from the outside | 3 | + | // Package ports declares every interface that crosses a layer, and the |
| 4 | - | // world. Adapters implement them; cmd wires them. | 4 | + | // parameter and result types of the use case interfaces. |
| 5 | + | // | |||
| 6 | + | // Outbound ports (ports.go) are what the use cases need from the outside | |||
| 7 | + | // world: adapters implement them. Inbound ports (services.go) are the use | |||
| 8 | + | // cases as the adapters that serve requests see them: the types of package usecase | |||
| 9 | + | // implement them. Adapters and use cases depend on this package and on domain, | |||
| 10 | + | // never on each other; internal/app wires the implementations. All the mocks are generated in | |||
| 11 | + | // ports/mocks. | |||
| 5 | package ports | 12 | package ports | ||
| 6 | 13 | ||||
| 7 | import ( | 14 | import ( | ||
A internal/ports/services.go +163 -0 collapsed to keep the page light
R internal/app/access.go → internal/usecase/access.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package app | 3 | + | package usecase |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
R internal/app/browse.go → internal/usecase/browse.go +97 -69 collapsed to keep the page light · renamed, 63% similar
R internal/app/browse_test.go → internal/usecase/browse_test.go +57 -44 renamed, 81% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package app_test | 3 | + | package usecase_test |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -15,22 +15,22 @@ import ( | |||||
| 15 | "github.com/stretchr/testify/mock" | 15 | "github.com/stretchr/testify/mock" | ||
| 16 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 17 | 17 | ||||
| 18 | - | "bnema.dev/ermite/internal/app" | |||
| 19 | "bnema.dev/ermite/internal/domain" | 18 | "bnema.dev/ermite/internal/domain" | ||
| 20 | "bnema.dev/ermite/internal/ports" | 19 | "bnema.dev/ermite/internal/ports" | ||
| 21 | "bnema.dev/ermite/internal/ports/mocks" | 20 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 21 | + | "bnema.dev/ermite/internal/usecase" | |||
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| 24 | type browseFixture struct { | 24 | type browseFixture struct { | ||
| 25 | meta *mocks.RepoMetaStore | 25 | meta *mocks.RepoMetaStore | ||
| 26 | reader *mocks.RepoReader | 26 | reader *mocks.RepoReader | ||
| 27 | - | browse *app.Browse | 27 | + | browse *usecase.Browse |
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | func newBrowseFixture(t *testing.T) *browseFixture { | 30 | func newBrowseFixture(t *testing.T) *browseFixture { | ||
| 31 | t.Helper() | 31 | t.Helper() | ||
| 32 | f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)} | 32 | f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)} | ||
| 33 | - | f.browse = app.NewBrowse(f.meta, f.reader) | 33 | + | f.browse = usecase.NewBrowse(f.meta, f.reader) |
| 34 | return f | 34 | return f | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| @@ -46,7 +46,7 @@ func oid(c string) domain.ObjectID { | |||||
| 46 | // The reader expectation of each is what must (not) run. | 46 | // The reader expectation of each is what must (not) run. | ||
| 47 | type browseOp struct { | 47 | type browseOp struct { | ||
| 48 | name string | 48 | name string | ||
| 49 | - | call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error | 49 | + | call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error |
| 50 | // expect registers the reader call a permitted caller makes. | 50 | // expect registers the reader call a permitted caller makes. | ||
| 51 | expect func(r *mocks.RepoReader) | 51 | expect func(r *mocks.RepoReader) | ||
| 52 | } | 52 | } | ||
| @@ -54,35 +54,35 @@ type browseOp struct { | |||||
| 54 | func browseOps() []browseOp { | 54 | func browseOps() []browseOp { | ||
| 55 | root := domain.TreePath{} | 55 | root := domain.TreePath{} | ||
| 56 | return []browseOp{ | 56 | return []browseOp{ | ||
| 57 | - | {"ListRefs", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 57 | + | {"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { |
| 58 | - | _, err := b.ListRefs(ctx, p, repo, app.ListRefsParams{}) | 58 | + | _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{}) |
| 59 | return err | 59 | return err | ||
| 60 | }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }}, | 60 | }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }}, | ||
| 61 | - | {"ResolveRevision", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 61 | + | {"ResolveRevision", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { |
| 62 | _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{}) | 62 | _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{}) | ||
| 63 | return err | 63 | return err | ||
| 64 | }, func(r *mocks.RepoReader) { | 64 | }, func(r *mocks.RepoReader) { | ||
| 65 | r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil) | 65 | r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil) | ||
| 66 | }}, | 66 | }}, | ||
| 67 | - | {"ResolveRefPath", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 67 | + | {"ResolveRefPath", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { |
| 68 | _, err := b.ResolveRefPath(ctx, p, repo, "main/x") | 68 | _, err := b.ResolveRefPath(ctx, p, repo, "main/x") | ||
| 69 | return err | 69 | return err | ||
| 70 | }, func(r *mocks.RepoReader) { | 70 | }, func(r *mocks.RepoReader) { | ||
| 71 | r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil) | 71 | r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil) | ||
| 72 | }}, | 72 | }}, | ||
| 73 | - | {"GetTree", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 73 | + | {"GetTree", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { |
| 74 | _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true) | 74 | _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true) | ||
| 75 | return err | 75 | return err | ||
| 76 | }, func(r *mocks.RepoReader) { | 76 | }, func(r *mocks.RepoReader) { | ||
| 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | ||
| 78 | }}, | 78 | }}, | ||
| 79 | - | {"ListPaths", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 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{}) | 80 | _, err := b.ListPaths(ctx, p, repo, domain.Revision{}) | ||
| 81 | return err | 81 | return err | ||
| 82 | }, func(r *mocks.RepoReader) { | 82 | }, func(r *mocks.RepoReader) { | ||
| 83 | r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil) | 83 | r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil) | ||
| 84 | }}, | 84 | }}, | ||
| 85 | - | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 85 | + | {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { |
| 86 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | 86 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | ||
| 87 | if rc != nil { | 87 | if rc != nil { | ||
| 88 | _ = rc.Close() | 88 | _ = rc.Close() | ||
| @@ -92,13 +92,13 @@ func browseOps() []browseOp { | |||||
| 92 | r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | 92 | r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | ||
| 93 | Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil) | 93 | Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil) | ||
| 94 | }}, | 94 | }}, | ||
| 95 | - | {"ListCommits", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 95 | + | {"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { |
| 96 | - | _, err := b.ListCommits(ctx, p, repo, app.ListCommitsParams{}) | 96 | + | _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{}) |
| 97 | return err | 97 | return err | ||
| 98 | }, func(r *mocks.RepoReader) { | 98 | }, func(r *mocks.RepoReader) { | ||
| 99 | r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil) | 99 | r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil) | ||
| 100 | }}, | 100 | }}, | ||
| 101 | - | {"GetCommit", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 101 | + | {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { |
| 102 | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}) | 102 | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}) | ||
| 103 | return err | 103 | return err | ||
| 104 | }, func(r *mocks.RepoReader) { | 104 | }, func(r *mocks.RepoReader) { | ||
| @@ -112,6 +112,7 @@ func browseOps() []browseOp { | |||||
| 112 | // not found; stored tokens need repo:read on the repository; and the Git | 112 | // not found; stored tokens need repo:read on the repository; and the Git | ||
| 113 | // reader is never reached when access is refused. | 113 | // reader is never reached when access is refused. | ||
| 114 | func TestBrowseAccess(t *testing.T) { | 114 | func TestBrowseAccess(t *testing.T) { | ||
| 115 | + | t.Parallel() | |||
| 115 | ctx := context.Background() | 116 | ctx := context.Background() | ||
| 116 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | 117 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | ||
| 117 | priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate} | 118 | priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate} | ||
| @@ -135,6 +136,7 @@ func TestBrowseAccess(t *testing.T) { | |||||
| 135 | for _, pr := range principals { | 136 | for _, pr := range principals { | ||
| 136 | for _, repo := range []domain.Repo{pub, priv} { | 137 | for _, repo := range []domain.Repo{pub, priv} { | ||
| 137 | t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) { | 138 | t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) { | ||
| 139 | + | t.Parallel() | |||
| 138 | f := newBrowseFixture(t) | 140 | f := newBrowseFixture(t) | ||
| 139 | f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil) | 141 | f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil) | ||
| 140 | allowed := pr.pub | 142 | allowed := pr.pub | ||
| @@ -155,12 +157,14 @@ func TestBrowseAccess(t *testing.T) { | |||||
| 155 | } | 157 | } | ||
| 156 | } | 158 | } | ||
| 157 | t.Run(op.name+"/missing repository", func(t *testing.T) { | 159 | t.Run(op.name+"/missing repository", func(t *testing.T) { | ||
| 160 | + | t.Parallel() | |||
| 158 | f := newBrowseFixture(t) | 161 | f := newBrowseFixture(t) | ||
| 159 | f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound) | 162 | f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound) | ||
| 160 | err := op.call(f.browse, ctx, domain.Root(), name(t, "nope")) | 163 | err := op.call(f.browse, ctx, domain.Root(), name(t, "nope")) | ||
| 161 | assert.ErrorIs(t, err, domain.ErrNotFound) | 164 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 162 | }) | 165 | }) | ||
| 163 | t.Run(op.name+"/zero name", func(t *testing.T) { | 166 | t.Run(op.name+"/zero name", func(t *testing.T) { | ||
| 167 | + | t.Parallel() | |||
| 164 | f := newBrowseFixture(t) | 168 | f := newBrowseFixture(t) | ||
| 165 | err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{}) | 169 | err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{}) | ||
| 166 | assert.ErrorIs(t, err, domain.ErrInvalidName) | 170 | assert.ErrorIs(t, err, domain.ErrInvalidName) | ||
| @@ -169,6 +173,7 @@ func TestBrowseAccess(t *testing.T) { | |||||
| 169 | } | 173 | } | ||
| 170 | 174 | ||||
| 171 | func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) { | 175 | func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) { | ||
| 176 | + | t.Parallel() | |||
| 172 | // A reader error is passed through for a caller who may read. | 177 | // A reader error is passed through for a caller who may read. | ||
| 173 | ctx := context.Background() | 178 | ctx := context.Background() | ||
| 174 | f := newBrowseFixture(t) | 179 | f := newBrowseFixture(t) | ||
| @@ -184,6 +189,7 @@ func ref(kind domain.RefKind, name string) domain.Ref { | |||||
| 184 | } | 189 | } | ||
| 185 | 190 | ||||
| 186 | func TestListRefsPagingAndKinds(t *testing.T) { | 191 | func TestListRefsPagingAndKinds(t *testing.T) { | ||
| 192 | + | t.Parallel() | |||
| 187 | ctx := context.Background() | 193 | ctx := context.Background() | ||
| 188 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | 194 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | ||
| 189 | all := []domain.Ref{ | 195 | all := []domain.Ref{ | ||
| @@ -201,7 +207,7 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||||
| 201 | } | 207 | } | ||
| 202 | return out | 208 | return out | ||
| 203 | } | 209 | } | ||
| 204 | - | setup := func(t *testing.T, refs []domain.Ref) *app.Browse { | 210 | + | setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse { |
| 205 | f := newBrowseFixture(t) | 211 | f := newBrowseFixture(t) | ||
| 206 | f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil) | 212 | f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil) | ||
| 207 | f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil) | 213 | f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil) | ||
| @@ -214,7 +220,7 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||||
| 214 | tok := "" | 220 | tok := "" | ||
| 215 | for pages := 0; ; pages++ { | 221 | for pages := 0; ; pages++ { | ||
| 216 | require.Less(t, pages, 10) | 222 | require.Less(t, pages, 10) | ||
| 217 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: tok}) | 223 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: tok}) |
| 218 | require.NoError(t, err) | 224 | require.NoError(t, err) | ||
| 219 | got = append(got, names(page.Refs)...) | 225 | got = append(got, names(page.Refs)...) | ||
| 220 | if tok = page.NextPageToken; tok == "" { | 226 | if tok = page.NextPageToken; tok == "" { | ||
| @@ -226,19 +232,19 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||||
| 226 | 232 | ||||
| 227 | t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) { | 233 | t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) { | ||
| 228 | b := setup(t, all[:4]) | 234 | b := setup(t, all[:4]) | ||
| 229 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2}) | 235 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2}) |
| 230 | require.NoError(t, err) | 236 | require.NoError(t, err) | ||
| 231 | require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs)) | 237 | require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs)) | ||
| 232 | 238 | ||||
| 233 | b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...)) | 239 | b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...)) | ||
| 234 | - | next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken}) | 240 | + | next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken}) |
| 235 | require.NoError(t, err) | 241 | require.NoError(t, err) | ||
| 236 | assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs)) | 242 | assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs)) | ||
| 237 | }) | 243 | }) | ||
| 238 | 244 | ||||
| 239 | t.Run("kinds filter", func(t *testing.T) { | 245 | t.Run("kinds filter", func(t *testing.T) { | ||
| 240 | b := setup(t, all) | 246 | b := setup(t, all) | ||
| 241 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}}) | 247 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}}) |
| 242 | require.NoError(t, err) | 248 | require.NoError(t, err) | ||
| 243 | assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs)) | 249 | assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs)) | ||
| 244 | assert.Empty(t, page.NextPageToken) | 250 | assert.Empty(t, page.NextPageToken) | ||
| @@ -247,24 +253,26 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||||
| 247 | t.Run("default and maximum page size", func(t *testing.T) { | 253 | t.Run("default and maximum page size", func(t *testing.T) { | ||
| 248 | var many []domain.Ref | 254 | var many []domain.Ref | ||
| 249 | many = append(many, ref(domain.RefKindHead, "HEAD")) | 255 | many = append(many, ref(domain.RefKindHead, "HEAD")) | ||
| 250 | - | for i := range 250 { | 256 | + | for i := range usecase.MaxRefPageSize + 100 { |
| 251 | - | many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%03d", i))) | 257 | + | many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i))) |
| 252 | } | 258 | } | ||
| 253 | b := setup(t, many) | 259 | b := setup(t, many) | ||
| 254 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{}) | 260 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{}) |
| 255 | require.NoError(t, err) | 261 | require.NoError(t, err) | ||
| 256 | - | assert.Len(t, page.Refs, app.DefaultPageSize) | 262 | + | assert.Len(t, page.Refs, usecase.DefaultPageSize) |
| 257 | assert.NotEmpty(t, page.NextPageToken) | 263 | assert.NotEmpty(t, page.NextPageToken) | ||
| 258 | 264 | ||||
| 259 | b = setup(t, many) | 265 | b = setup(t, many) | ||
| 260 | - | page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 100000}) | 266 | + | page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000}) |
| 261 | require.NoError(t, err) | 267 | require.NoError(t, err) | ||
| 262 | - | assert.Len(t, page.Refs, app.MaxPageSize) | 268 | + | assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs") |
| 269 | + | assert.NotEmpty(t, page.NextPageToken) | |||
| 263 | }) | 270 | }) | ||
| 264 | 271 | ||||
| 265 | t.Run("bad input never reaches the reader", func(t *testing.T) { | 272 | t.Run("bad input never reaches the reader", func(t *testing.T) { | ||
| 273 | + | t.Parallel() | |||
| 266 | f := newBrowseFixture(t) | 274 | f := newBrowseFixture(t) | ||
| 267 | - | for _, params := range []app.ListRefsParams{ | 275 | + | for _, params := range []ports.ListRefsParams{ |
| 268 | {PageSize: -1}, | 276 | {PageSize: -1}, | ||
| 269 | {Kinds: []domain.RefKind{"nope"}}, | 277 | {Kinds: []domain.RefKind{"nope"}}, | ||
| 270 | {PageToken: "!!!"}, | 278 | {PageToken: "!!!"}, | ||
| @@ -273,26 +281,28 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||||
| 273 | _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params) | 281 | _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params) | ||
| 274 | assert.Error(t, err, "%+v", params) | 282 | assert.Error(t, err, "%+v", params) | ||
| 275 | } | 283 | } | ||
| 276 | - | _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: -1}) | 284 | + | _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: -1}) |
| 277 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | 285 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | ||
| 278 | - | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{"x"}}) | 286 | + | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{"x"}}) |
| 279 | assert.ErrorIs(t, err, domain.ErrInvalidRefKind) | 287 | assert.ErrorIs(t, err, domain.ErrInvalidRefKind) | ||
| 280 | - | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageToken: "!!!"}) | 288 | + | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageToken: "!!!"}) |
| 281 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | 289 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | ||
| 282 | }) | 290 | }) | ||
| 283 | } | 291 | } | ||
| 284 | 292 | ||||
| 285 | func TestListCommitsPaging(t *testing.T) { | 293 | func TestListCommitsPaging(t *testing.T) { | ||
| 294 | + | t.Parallel() | |||
| 286 | ctx := context.Background() | 295 | ctx := context.Background() | ||
| 287 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | 296 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | ||
| 288 | p := mustPath(t, "d/f") | 297 | p := mustPath(t, "d/f") | ||
| 289 | 298 | ||||
| 290 | t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) { | 299 | t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) { | ||
| 300 | + | t.Parallel() | |||
| 291 | f := newBrowseFixture(t) | 301 | f := newBrowseFixture(t) | ||
| 292 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | 302 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | ||
| 293 | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}). | 303 | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}). | ||
| 294 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | 304 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | ||
| 295 | - | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageSize: 10}) | 305 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageSize: 10}) |
| 296 | require.NoError(t, err) | 306 | require.NoError(t, err) | ||
| 297 | require.Len(t, page.Commits, 1) | 307 | require.Len(t, page.Commits, 1) | ||
| 298 | require.NotEmpty(t, page.NextPageToken) | 308 | require.NotEmpty(t, page.NextPageToken) | ||
| @@ -304,7 +314,7 @@ func TestListCommitsPaging(t *testing.T) { | |||||
| 304 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | 314 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | ||
| 305 | // The revision of the request is ignored once there is a token. | 315 | // The revision of the request is ignored once there is a token. | ||
| 306 | rev, _ := domain.ParseRevision("moved") | 316 | rev, _ := domain.ParseRevision("moved") | ||
| 307 | - | next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{ | 317 | + | next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{ |
| 308 | Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken, | 318 | Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken, | ||
| 309 | }) | 319 | }) | ||
| 310 | require.NoError(t, err) | 320 | require.NoError(t, err) | ||
| @@ -313,14 +323,15 @@ func TestListCommitsPaging(t *testing.T) { | |||||
| 313 | }) | 323 | }) | ||
| 314 | 324 | ||||
| 315 | t.Run("a token is bound to its path and must be well formed", func(t *testing.T) { | 325 | t.Run("a token is bound to its path and must be well formed", func(t *testing.T) { | ||
| 326 | + | t.Parallel() | |||
| 316 | f := newBrowseFixture(t) | 327 | f := newBrowseFixture(t) | ||
| 317 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | 328 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | ||
| 318 | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | 329 | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | ||
| 319 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | 330 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | ||
| 320 | - | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p}) | 331 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p}) |
| 321 | require.NoError(t, err) | 332 | require.NoError(t, err) | ||
| 322 | 333 | ||||
| 323 | - | for _, params := range []app.ListCommitsParams{ | 334 | + | for _, params := range []ports.ListCommitsParams{ |
| 324 | {PageToken: page.NextPageToken}, // no path | 335 | {PageToken: page.NextPageToken}, // no path | ||
| 325 | {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path | 336 | {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path | ||
| 326 | {Path: p, PageToken: "garbage"}, | 337 | {Path: p, PageToken: "garbage"}, | ||
| @@ -333,11 +344,12 @@ func TestListCommitsPaging(t *testing.T) { | |||||
| 333 | }) | 344 | }) | ||
| 334 | 345 | ||||
| 335 | t.Run("a token cannot skip deeper than the cap", func(t *testing.T) { | 346 | t.Run("a token cannot skip deeper than the cap", func(t *testing.T) { | ||
| 347 | + | t.Parallel() | |||
| 336 | f := newBrowseFixture(t) | 348 | f := newBrowseFixture(t) | ||
| 337 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | 349 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | ||
| 338 | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | 350 | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | ||
| 339 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once() | 351 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once() | ||
| 340 | - | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p}) | 352 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p}) |
| 341 | require.NoError(t, err) | 353 | require.NoError(t, err) | ||
| 342 | raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken) | 354 | raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken) | ||
| 343 | require.NoError(t, err) | 355 | require.NoError(t, err) | ||
| @@ -348,24 +360,25 @@ func TestListCommitsPaging(t *testing.T) { | |||||
| 348 | return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":"))) | 360 | return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":"))) | ||
| 349 | } | 361 | } | ||
| 350 | 362 | ||||
| 351 | - | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == app.MaxCommitSkip })). | 363 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })). |
| 352 | Return(ports.CommitPage{Head: oid("a")}, nil).Once() | 364 | Return(ports.CommitPage{Head: oid("a")}, nil).Once() | ||
| 353 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip)}) | 365 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)}) |
| 354 | require.NoError(t, err, "the cap itself is allowed") | 366 | require.NoError(t, err, "the cap itself is allowed") | ||
| 355 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip + 1)}) | 367 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)}) |
| 356 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | 368 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | ||
| 357 | }) | 369 | }) | ||
| 358 | 370 | ||||
| 359 | t.Run("page size", func(t *testing.T) { | 371 | t.Run("page size", func(t *testing.T) { | ||
| 372 | + | t.Parallel() | |||
| 360 | f := newBrowseFixture(t) | 373 | f := newBrowseFixture(t) | ||
| 361 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2) | 374 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2) | ||
| 362 | - | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: app.DefaultPageSize}).Return(ports.CommitPage{}, nil) | 375 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil) |
| 363 | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil) | 376 | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil) | ||
| 364 | - | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{}) | 377 | + | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) |
| 365 | require.NoError(t, err) | 378 | require.NoError(t, err) | ||
| 366 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: 5000}) | 379 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: 5000}) |
| 367 | require.NoError(t, err) | 380 | require.NoError(t, err) | ||
| 368 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: -3}) | 381 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: -3}) |
| 369 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | 382 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | ||
| 370 | }) | 383 | }) | ||
| 371 | 384 | ||||
| @@ -375,10 +388,10 @@ func TestListCommitsPaging(t *testing.T) { | |||||
| 375 | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | 388 | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | ||
| 376 | f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything). | 389 | f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything). | ||
| 377 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | 390 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | ||
| 378 | - | page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, app.ListCommitsParams{}) | 391 | + | page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, ports.ListCommitsParams{}) |
| 379 | require.NoError(t, err) | 392 | require.NoError(t, err) | ||
| 380 | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | 393 | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | ||
| 381 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, app.ListCommitsParams{PageToken: page.NextPageToken}) | 394 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, ports.ListCommitsParams{PageToken: page.NextPageToken}) |
| 382 | assert.ErrorIs(t, err, domain.ErrNotFound) | 395 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 383 | }) | 396 | }) | ||
| 384 | } | 397 | } | ||
A internal/usecase/browsecache.go +166 -0 collapsed to keep the page light
A internal/usecase/browsecache_test.go +360 -0 collapsed to keep the page light
R internal/app/events.go → internal/usecase/events.go +14 -46 collapsed to keep the page light · renamed, 89% similar
A internal/usecase/events_test.go +883 -0 collapsed to keep the page light
A internal/usecase/export_test.go +8 -0
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import "bnema.dev/ermite/internal/domain" | |||
| 6 | + | ||||
| 7 | + | // AuthorOf exposes authorOf to the tests. | |||
| 8 | + | func AuthorOf(p domain.Principal) string { return authorOf(p) } | |||
R internal/app/maintenance.go → internal/usecase/maintenance.go +3 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package app | 3 | + | package usecase |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -38,6 +38,8 @@ type Maintainer struct { | |||||
| 38 | wg sync.WaitGroup | 38 | wg sync.WaitGroup | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | var _ ports.MaintenanceScheduler = (*Maintainer)(nil) | |||
| 42 | + | ||||
| 41 | type maintEntry struct { | 43 | type maintEntry struct { | ||
| 42 | timer *time.Timer // pending run, nil while running or idle | 44 | timer *time.Timer // pending run, nil while running or idle | ||
| 43 | running bool | 45 | running bool | ||
A internal/usecase/maintenance_test.go +219 -0 collapsed to keep the page light
R internal/app/mergerequests.go → internal/usecase/mergerequests.go +30 -103 collapsed to keep the page light · renamed, 89% similar
R internal/app/mergerequests_delete_test.go → internal/usecase/mergerequests_delete_test.go +26 -15 collapsed to keep the page light · renamed, 77% similar
R internal/app/mergerequests_integration_test.go → internal/usecase/mergerequests_integration_test.go +159 -131 collapsed to keep the page light · renamed, 75% similar
R internal/app/mergerequests_test.go → internal/usecase/mergerequests_test.go +17 -8 collapsed to keep the page light · renamed, 85% similar
R internal/app/paging.go → internal/usecase/paging.go +27 -29 collapsed to keep the page light · renamed, 79% similar
R internal/app/pushes.go → internal/usecase/pushes.go +10 -16 collapsed to keep the page light · renamed, 92% similar
R internal/app/pushes_test.go → internal/usecase/pushes_test.go +44 -42 collapsed to keep the page light · renamed, 90% similar
R internal/app/repos.go → internal/usecase/repos.go +5 -11 renamed, 95% similar
| @@ -1,8 +1,8 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | // Package app holds the use cases of ermite. It depends on the domain and on | 3 | + | // Package usecase holds the use cases of ermite. It depends on the domain and on |
| 4 | // the ports only. | 4 | // the ports only. | ||
| 5 | - | package app | 5 | + | package usecase |
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| @@ -23,20 +23,14 @@ type Repos struct { | |||||
| 23 | clock ports.Clock | 23 | clock ports.Clock | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | + | var _ ports.RepoService = (*Repos)(nil) | |||
| 27 | + | ||||
| 26 | // NewRepos builds the repository use cases over their ports. Changes are | 28 | // NewRepos builds the repository use cases over their ports. Changes are | ||
| 27 | // published on bus. | 29 | // published on bus. | ||
| 28 | func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, clock ports.Clock) *Repos { | 30 | func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, clock ports.Clock) *Repos { | ||
| 29 | return &Repos{meta: meta, git: git, bus: bus, clock: clock} | 31 | return &Repos{meta: meta, git: git, bus: bus, clock: clock} | ||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | - | // CreateParams are the settings of a new repository. | |||
| 33 | - | type CreateParams struct { | |||
| 34 | - | Name domain.RepoName | |||
| 35 | - | Description string | |||
| 36 | - | // Visibility defaults to private when empty. | |||
| 37 | - | Visibility domain.Visibility | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the | 34 | // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the | ||
| 41 | // metadata (which fails with domain.ErrAlreadyExists if the name is taken, | 35 | // metadata (which fails with domain.ErrAlreadyExists if the name is taken, | ||
| 42 | // also when a concurrent Create wins) and publishes the directory. A failure | 36 | // also when a concurrent Create wins) and publishes the directory. A failure | ||
| @@ -45,7 +39,7 @@ type CreateParams struct { | |||||
| 45 | // | 39 | // | ||
| 46 | // The principal needs repo:write on the name: anonymous callers get | 40 | // The principal needs repo:write on the name: anonymous callers get | ||
| 47 | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | 41 | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | ||
| 48 | - | func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) { | 42 | + | func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { |
| 49 | if params.Name.IsZero() { | 43 | if params.Name.IsZero() { | ||
| 50 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | 44 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | ||
| 51 | } | 45 | } | ||