detached fc4d564

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
commit
fc4d564c0128b4c32669e16bc922e44f863c3426
author
bnema · · 2026-10-12 13:17 UTC
merge of
d5cc8fe + 650dc0a · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
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 the14 # *_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 - ineffassign7 - ineffassign
8 - misspell8 - misspell
9 - staticcheck9 - staticcheck
10+ - tparallel
10 - unconvert11 - unconvert
11 - unused12 - unused
12 exclusions:13 exclusions:
M .hexcheck.yaml +46 -2
@@ -4,10 +4,10 @@ components:
4 role: core4 role: core
5 paths:5 paths:
6 - internal/domain/**6 - internal/domain/**
7- app:7+ usecase:
8 role: usecase8 role: usecase
9 paths:9 paths:
10- - internal/app/**10+ - internal/usecase/**
11 ports:11 ports:
12 role: ports12 role: ports
13 paths:13 paths:
@@ -16,10 +16,23 @@ components:
16 role: adapter16 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: entrypoint26 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: entrypoint37 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/ports1+# Mocks of the ports, generated in internal/ports/mocks. Regenerate with:
2+# go generate ./internal/ports
2 template: testify3 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/ermite28 -o /out/ermite ./cmd/ermite
29 29
30 FROM alpine:3.2430 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 ## Develop113 ## 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 &amp; 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&amp;dir=asc">name </a>
55-</th>
56-<th class="c-desc" aria-sort="none">
57-<a href="/?sort=desc&amp;dir=asc">description </a>
58-</th>
59-<th class="c-when" aria-sort="descending">
60-<a href="/?sort=push&amp;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&amp;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 &amp; 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 &amp; 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 &amp; 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 goroutine219 // 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 read261 // 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 library280 // 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 Backend87 ResolveRefPath Backend
88 // GetTree is a directory listing without last commits.88 // GetTree is a directory listing without last commits.
89 GetTree Backend89 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 Backend92 GetTreeLastCommits Backend
92- OpenBlob Backend93+ // 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 Backend98 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 Backend102 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 the117 // 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.GetTree354+ 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 shared7+// The rule: a method is routed to ermite-git when it passes the shared
8 // conformance table (TestConformance, TestCompareWithCLI) and its benchmark8 // conformance table (TestConformance, TestCompareWithCLI) and its benchmark
9-// beats the CLI adapter in time, in bytes AND in allocations per call, warm9+// 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 must11+// COLD run does not lose in time by more than the open itself (about 65 µs,
12-// win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and12+// 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. A13+// 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: the14+// 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 and15+// 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, the22 // 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 (a23 // 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 win101 // 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 ba102 // 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 reused104+// 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 (time109+// - ListRefs (every repository page, through loadRepo): 905 µs against 24 µs
104-// is won since the zlib change, bytes are not) and OpenBlob, whose110+// 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 is111+// 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 little113+// - ResolveRevision (name, full id, abbreviation) and ResolveRefPath: 4 to 8
108-// work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200114+// 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 few115+// 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 the117+// - 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 only118+// 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 a119+// 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 in120+// 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+last122+// its own. Both routed.
117-// also wins every line, but GetTreeLastCommits covers GetTree root+last123+// - GetTree without last commits wins warm in time everywhere (13 µs against
118-// too, which loses cold allocations on lazygit (4204 against 3073): it is124+// 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 repository125+// (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 the131+// - OpenBlob by size: below 1 MiB the small blob loses warm bytes and
126-// constants it wins: ListRefs, ResolveRefPath and ResolveRevision win warm by 4132+// 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 e31 return e
28- case <-time.After(2 * time.Second):32+ default:
29 t.Fatal("no event")33 t.Fatal("no event")
30 return nil34 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.Event87+ 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 panic117+ 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.WaitGroup129+ 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/hooks32 // core.hooksPath=<data>/run/hooks
33 // receive.fsckObjects=true33 // receive.fsckObjects=true
34 // uploadpack.allowFilter=true34 // 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.Second38 defaultIdleTimeout = 60 * time.Second
38 defaultMaxProcesses = 1639 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 the54+// - At most max processes are kept, all repositories together. Starting
46-// least recently used idle process; a process that is in use is never55+// another one first evicts the least recently used idle process; a
47-// evicted, so the bound is soft when more than max repositories are read56+// 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 then60 // - 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 a61 // 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 string64+ bin string
54- env []string65+ env []string
55- idle time.Duration66+ idle time.Duration
56- max int67+ max int // processes, all repositories together
68+ perRepo int // processes of one repository
57 69
58 mu sync.Mutex70 mu sync.Mutex
59- procs map[string]*catProc71+ 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 OpenBlob74 streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob
61 closed bool75 closed bool
62 wg sync.WaitGroup // reapers of started processes76 wg sync.WaitGroup // reapers of started processes
@@ -71,11 +85,10 @@ type catProc struct {
71 in io.WriteCloser85 in io.WriteCloser
72 out *bufio.Reader86 out *bufio.Reader
73 stderr *tailBuffer87 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 error89+ broken bool // set by the holder on a protocol error
77 90
78- users int // requests waiting for or holding sem91+ busy bool // a request holds the process
79 lastUsed time.Time92 lastUsed time.Time
80 timer *time.Timer93 timer *time.Timer
81 gen uint6494 gen uint64
@@ -90,13 +103,14 @@ func newCatPool(bin string, env []string, idle time.Duration, maxProcs int) *cat
90 maxProcs = defaultMaxProcesses103 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 in113+// 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 err149 return err
137 }150 }
138 151
@@ -142,58 +155,149 @@ func (p *catPool) isClosed() bool {
142 return p.closed155 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, ErrClosed167 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 error171+ 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.dead207+}
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, nil210+// 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 one240+// 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 return286 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.gen302 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 return312 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 goroutine318 // 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 return322 return
219 }323 }
220 cp.dead = true324 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.env352 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] = cp371+ 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, nil379 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 = true395 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 gitcli3 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-file12 // 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 []int17 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 pids24 return pids
@@ -44,8 +51,10 @@ func (g *Git) FireIdleTimers() {
44 gen uint6451 gen uint64
45 }52 }
46 var all []armed53 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 uint6471 gen uint64
63 }72 }
64 var all []armed73 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 many85 // 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 githttp7 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 Authenticator66+ Auth ports.AuthService
93- Repos Repos67+ Repos ports.RepoLookup
94- Pushes Pushes68+ Pushes ports.PushService
95 Backend ports.GitBackend69 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 HookTokens71+ 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.Prefix73 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.Limiter75+ 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 int6478 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 Config112 cfg Config
110- limiter *clientlimit.Limiter113+ limiter ports.FailureLimiter
111 maxBody int64114 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 = DefaultMaxBodyBytes130 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 h137 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 route188 var rt route
173 var name domain.RepoName189 var name domain.RepoName
@@ -225,7 +241,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
225 return241 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 the247 // 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 = -1468 req.ContentLength = -1
389 req.TransferEncoding = nil469 req.TransferEncoding = nil
@@ -391,6 +471,34 @@ func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt rout
391 return req, 0471 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 its502 // 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.Mutex31+// environment and the body it received. Like Git, it reads the whole request
31- seen []string32+// 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(), nil50+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}}), nil52+ 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.ErrUnauthenticated61+ 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}, nil75+ 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}, nil78+ 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.Mutex84+ r.mu.Lock()
63- calls []bool // the create flag of each Open85+ defer r.mu.Unlock()
64- err error86+ 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.ErrUnauthenticated94+ return c
73- }
74- return f.err
75 }95 }
76 96
77-type fakeTokens struct {97+func (r *rig) authenticateCalls() int {
78- mu sync.Mutex98+ r.mu.Lock()
79- released int99+ defer r.mu.Unlock()
80- repo domain.RepoName100+ 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 = r106+ 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 the109+func (r *rig) hookedRepo() domain.RepoName {
92-// environment and the body it received. Like Git, it reads the whole request110+ r.mu.Lock()
93-// before it answers (net/http cannot read a request body once the response111+ 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.Handler116+// reader token, a public repository "pub" and a private one "priv".
105- srv *httptest.Server117+func (r *rig) stubUseCases(repos *mocks.RepoLookup) {
106- pushes *fakePushes118+ r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn(
107- backend *mocks.GitBackend119+ func(_ context.Context, secret string) (domain.Principal, error) {
108- hooks *fakeTokens120+ r.mu.Lock()
109- auth *fakeAuth121+ 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 []bool132+ repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
112- backendErr error133+ 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 string232 name string
177 method string233 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 ignored263 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 code281 return code
223 }282 }
224 assert.Equal(t, 200, get())283 assert.Equal(t, 200, get())
225- r.pushes.err = domain.ErrForbidden284+ r.setPushErr(domain.ErrForbidden)
226 assert.Equal(t, 403, get())285 assert.Equal(t, 403, get())
227- r.pushes.err = domain.ErrNotFound286+ r.setPushErr(domain.ErrNotFound)
228 assert.Equal(t, 404, get())287 assert.Equal(t, 404, get())
229- r.pushes.err = domain.ErrUnauthenticated288+ 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 = nil294+ r.setPushErr(nil)
236- r.pushes.calls = nil295+ 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 GiB408 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 string436 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.ErrUnexpectedEOF473 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.Second26 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 string40 path string
49- hooks PushHooks41+ hooks ports.PushHookService
50 42
51 mu sync.RWMutex43 mu sync.RWMutex
52 tokens map[string]hookEntry44 tokens map[string]hookEntry
@@ -57,10 +49,10 @@ type HookServer struct {
57 wg sync.WaitGroup49 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: the202 // 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 bool208 var cancelled, hasDeadline bool
248 var ctxMu sync.Mutex209 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- <-proceed212+ close(started)
252- ctxMu.Lock()213+ <-proceed
253- defer ctxMu.Unlock()214+ ctxMu.Lock()
254- cancelled = ctx.Err() != nil215+ 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.WaitGroup263 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.Buffer291 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.Buffer309 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 httpserver3 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, before30 // 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 string38 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 []Check45 HealthChecks []Check
41 // Routes are added to the router next to /-/health.46 // Routes are added to the router next to /-/health.
42 Routes []Route47 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 failure59 // 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 checked69+ 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 nil78 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) with3 // 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 cases4 // 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 error5+// 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 in6+// mapping.
7-// process (see InProcessClient).
8 package rpc7 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 not23 // 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 Authenticator33+ Auth ports.AuthService
41- Repos *app.Repos34+ Repos ports.RepoService
42- Tokens *app.Tokens35+ Tokens ports.TokenService
43- Browse *app.Browse36+ Browse ports.BrowseService
44- Events *app.Events37+ Events ports.EventService
45 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.38 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
46- MergeRequests *app.MergeRequests39+ MergeRequests ports.MergeRequestService
47- SigningKey *app.SigningKey40+ SigningKey ports.SigningKeyService
48 Version string41 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.Prefix43 TrustedProxies []netip.Prefix
51 // Limiter counts failed authentications; nil builds a default one. Share44 // Limiter counts failed authentications; nil builds a default one. Share
52 // it with the other adapters that authenticate, so that a client has one45 // 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.Limiter47+ // 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.Sessions50+ // 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 p96 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.Settings59 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 no362 // 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 out397 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 to22 // 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 added23 // 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 value113 // 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.Violations272 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, true57 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, false62 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 a65 // 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. The17 // 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 for18 // 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 streams20 // 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 headers26 // 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 nil34 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 << 1019 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 on31 // 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 its34+// 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. What35+// 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 context36+// 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 Authenticator38+ auth ports.AuthService
41- limiter *clientlimit.Limiter39+ limiter ports.FailureLimiter
42 trusted []netip.Prefix40 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 proven49 // 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 an87+// 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 c90 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 here86 //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.UpdateParams88+ 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 string17 version string
18- key *app.SigningKey18+ 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 browser14 // 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 tests21 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 pinned24 `*_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 []byte35 body []byte
36 typ string36 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]) + ext76 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 + name84 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 string46 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.BlobInfo52+ 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 []byte54 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, err63 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, errNotFound112 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, err116 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, img145 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 = last185 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 resolved189 // 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.instance194 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 cannot199 // 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 nil204 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 &row207 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 the17 // 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 the91 // 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, err102 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, err106 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 = &cm124 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 = invisible143 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.instance148 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 p170 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 []string185 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 := 0189 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, with45 // 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 the46+// 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, errNotFound55 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, err60 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, errNotFound65 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 == 0104 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 nil134 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 continue138 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 <input11 <input
12 id="commit-filter"12 id="commit-filter"
13 type="search"13 type="search"
14- data-filter14+ 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, a28 // 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_TAG31+ 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 []string60 heads, tags []string
52 ref, short string61 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 branch16 // 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, which27 // 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 merge28 // 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, errNotFound39 return nil, 0, errNotFound
38 }40 }
39- tgt := d.head41+ 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.Commit53+ var ahead []domain.Commit
52 exact, behind := false, false54 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, err61 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, err86 return nil, 0, err
85 }87 }
86 p.HasDiff = true88 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 = n92 p.Note = n
91 }93 }
92 lines, invisible := diffLines(p.Files)94 lines, invisible := diffLines(p.Files)
93 p.Invisible = invisible95 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.MergeRequest105+ 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, err109 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.instance129 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 = false131 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 source142 // 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 source143 // 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, nil149 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, err156 return nil, false, false, err
155 }157 }
156- for _, c := range res.Msg.GetCommits() {158+ for _, c := range res.Commits {
157- onTarget[c.GetId()] = true159+ onTarget[c.ID.String()] = true
158 }160 }
159- if tokenT = res.Msg.GetNextPageToken(); tokenT == "" {161+ if tokenT = res.NextPageToken; tokenT == "" {
160 break162 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, err169 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 the174 // 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, nil176+ 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, nil182 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 }), nil185+ 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, err196 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, nil200 return m, nil
201 }201 }
202 }202 }
203- if token = res.Msg.GetNextPageToken(); token == "" {203+ if token = res.NextPageToken; token == "" {
204 break204 break
205 }205 }
206 }206 }
M internal/adapters/web/components.templ +3 -2
@@ -22,9 +22,10 @@ templ CloneBlock(c Clone) {
22 <button22 <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 Markdown77 // 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 an19 // 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 []DiffHunk101 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 trailing111 // 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 s222 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 dl237 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 := 0242 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 read252 // 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 no283 // 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 []string286 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 when115 // 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 <a143 <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 control16 // Trojan Source (ADR 0017): direction controls, zero-width and control
17 // characters are shown as markers; ordinary text, tabs and non-Latin letters17 // 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 string22 name, in string
22 want []Seg23 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 empty53 // 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_DELETION56+ t.Parallel()
57+ ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion
55 var ls []DiffLine58 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 nothing143 // 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.Builder147 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 &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+150 assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</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.Buffer30 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, any37+// 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 opens24 // 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 follows25 // 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 into26+// 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=home29 // 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, one56 // 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, so61 // 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.Second66 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 timeout69 // 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.Second71 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- return149+ 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 *recvd166 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 stopping171 return // the server is stopping
155 }172 }
173+ if m.err != nil {
174+ s.denyEvents(ctx, w, m.err)
175+ return
176+ }
156 first = &m177 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 return179 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 false203 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 sends208 // 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, and209 // 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 = gap224 frags = gap
207 }225 }
208- return out.send(m.ev.GetId(), frags) == nil226+ return out.send(m.ev.ID, frags) == nil
209 }227 }
210 if first != nil && !handle(*first) {228 if first != nil && !handle(*first) {
211 return229 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.Event261+ ev ports.WatchEvent
244 err error262 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 a282+// 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 private283 // 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.UnimplementedEventServiceHandler65+type watchProbe struct {
60 err error66 err error
61- events []*pb.Event67+ events []ports.WatchEvent
62 opened atomic.Int3268 opened atomic.Int32
63 closed atomic.Int3269 closed atomic.Int32
64- resume atomic.Value70+ 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 ones75+ 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.err95 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 err102 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 nil110 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 err113 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.UnimplementedRepoServiceHandler121+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 = writeTimeout136 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 the156 // 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; the180 // 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 goes283 // 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 rows319 // 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 nil340 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, like345+// 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- fakeSessions348+ t.Helper()
290- ended atomic.Bool349+ 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, not353 // 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 sent354 // 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 test360 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 []string379 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 := false409 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 headers419 // 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 on55 // 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, nil59 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 read80 // 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 was81+// 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) removes82+// 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 string85 var name string
58 fresh := false86 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(), true95+ name, fresh = ev.Repo.String(), true
68 default:96 default:
69 return nil, nil97 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, err104 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 = fresh107 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}, err109 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 page121 // 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, nil123 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 visible128 return gap, nil // no longer visible
101 case err != nil:129 case err != nil:
102 return nil, err130 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 branch132 return gap, nil // the description, the default branch
105 }133 }
106 return nil, nil134 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, nil148 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, err165 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}, err168 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, err175+ 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, err237+ 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}, err269+}
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 the286 // 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, nil292 return nil, nil
163 }293 }
164 tip, touched := "", false294 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(), true297+ 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, err317 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 may320 // 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 being321 // 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 branch322 // 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, false324 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 = true327 found = true
193 break328 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, nil380 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, nil386 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 links72 // 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 slot78+// 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 shows84 // 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 is85 // 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 ermiteLive86 // 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 <span94 <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 web3 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, false163 return nil, false
163 }164 }
164 return toks, true165 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.Builder169+// 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 after193 // 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 the66 // 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_TAG143+ 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 string151 in, name, path string
143 detached, ok bool152 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 = 100018 const maxRepos = 1000
20 19
21-// pageSize is the page size asked from the list RPCs: the largest the API20+// pageSize is the page size asked from the commit lists: the largest the use
22-// accepts.21+// case accepts for them.
23 const pageSize = 10022 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.Repo26+// 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, err35 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 break45 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 row135 return row
127 }136 }
128 137
129 // repoCmds are the repositories as targets of the ":" command line, but the138 // 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 []CmdItem141 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 out147 return out
M internal/adapters/web/keys.templ +23 -63
@@ -3,72 +3,32 @@
3 package web3 package web
4 4
5 // Keys is the keyboard map. ermite.js turns keys into k-<name> events on5 // 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 that6+// <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 <div9 <div
10 id="keys"10 id="keys"
11 hidden11 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 string24 name, src string
24 absent []string25 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 MergeRequestService20 // The merge request pages (ADR 0017). They read through MergeRequestService
21-// and GitService as the visitor of the request: anonymous sees the merge21+// and BrowseService as the visitor of the request: anonymous sees the merge
22 // requests of public repositories, the signed-in owner those of every22 // 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 = 5048 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 = 2050 maxMRPages = 20
51 // maxOpenPages bounds the pages read for the open merge requests, which51 // 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, nil260 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 row298 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 closed301 // 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 of302 // 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, err307 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, err321 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 break327 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 := token335 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, err341 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 break350 break
351 }351 }
@@ -363,10 +363,10 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component,
363 p.Done[0].Cursor = true363 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 continue367 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, err406 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, err410 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_OPEN415+ 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.Author434 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 continue474 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 = invisible520 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.instance532 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 = true547 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 = true549 l.HasRail = true
556 return MergeRequest(l, p), http.StatusOK, nil550 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 true557 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, false571 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 = true576+ 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 = true579+ 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) lists629 // 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 numbers630 // 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 string640 path string
647- side pb.DiffSide641+ side domain.DiffSide
648 line int642 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}] = ln654+ 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.ID656 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}] = ln660+ 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.ID662 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 nil670 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 *DiffLine691 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_NEW719+ side := domain.SideNew
725 if on.side == "old" {720 if on.side == "old" {
726- side = pb.DiffSide_DIFF_SIDE_OLD721+ 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 <input16 <input
17 id="mr-filter"17 id="mr-filter"
18 type="search"18 type="search"
19- data-filter19+ 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 gate18 // 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 the19+// (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, with20 // 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 a21+// "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 owner22+// 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 without23+// 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 is26 // 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 and46+// 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.Error67+ 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, err142 return "", back, err
135 }143 }
136- return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil144+ 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, or155 // 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_NEW173+ params.Side = domain.SideNew
158 case "old":174 case "old":
159- req.Side = pb.DiffSide_DIFF_SIDE_OLD175+ 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, err182 return "", back, err
167 }183 }
168- return back + "#c" + res.Msg.GetComment().GetId(), back, nil184+ 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 API193+//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, err207 return "", back, err
190 }208 }
191- return back + "#c" + res.Msg.GetComment().GetId(), back, nil209+ // 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, err258 return "", back, err
204 }259 }
205 return back, back, nil260 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.MergeMode274+ 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_FORWARD277+ mode = domain.MergeModeFastForward
223 case "squash":278 case "squash":
224- mode = pb.MergeMode_MERGE_MODE_SQUASH279+ 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, err294 return "", back, err
233 }295 }
234 return back, back, nil296 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, err317 return "", back, err
250 }318 }
251 return back, back, nil319 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 line333 // 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-pick40+ 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 <input10 <input
11 id="home-filter"11 id="home-filter"
12 type="search"12 type="search"
13- data-filter13+ 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 <input94 <input
95 id="ref-filter"95 id="ref-filter"
96 type="search"96 type="search"
97- data-filter97+ 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 the58 // 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.Int3263 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.WaitGroup87 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 not125 // 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.Int32130 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 is176 // 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 it223 // 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 nothing246 // 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.Once252 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 the279 // 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 repository302 // 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 build18 // 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, for28 // 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 never29 // 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 authorized30 // 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 the31+// 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, with32 // 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 never33 // 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 ETag34 // 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 return44 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 return54 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 return60 return
57 }61 }
58 62
59 // The validator names the variant that would be served: the gzip bytes63 // 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 return82 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 return87 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 stop127 // 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, err134 return nil, err
131 }135 }
132- if res.Msg.GetCommitId() != sha {136+ if list.CommitID.String() != sha {
133 return nil, errNotFound137 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 are149 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, err151 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, nil161 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 counts25+// 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.GitServiceClient28+// pathsBrowse resolves any full id to itself and lists a.go and b/c.go (or
28- lists atomic.Int3229+// 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 nil30+// 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()}), nil34+ 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.paths50+ 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}), nil53+ 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 is58 // 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 string149+func TestPathsListFailure(t *testing.T) {
113- want bool150+ 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 string173+ t.Parallel()
142- want bool174+ 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 the14 // 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 string19 name, in string
19 want []TextPart20 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.Buffer45 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, `&lt;b&gt;x&lt;/b&gt; &amp; <a href="http://x.test/?a=1&amp;b=2" rel="noopener nofollow ugc">http://x.test/?a=1&amp;b=2</a>`, b.String())47 assert.Equal(t, `&lt;b&gt;x&lt;/b&gt; &amp; <a href="http://x.test/?a=1&amp;b=2" rel="noopener nofollow ugc">http://x.test/?a=1&amp;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.Minute21 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 can26 // 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 an31 // 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 what32 // 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 return47 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 turn73 // 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). The74 // 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 return89 return
73 }90 }
74 b.buf = nil91 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 return104 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, so108+ // 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{}, "", err120+ 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{}, "", errNotFound124+ 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(), nil126+ 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, false133 return img, false
111 }134 }
112- if !b.info.GetBinary() {135+ if !b.info.Binary {
113 return "text/plain; charset=utf-8", false136 return "text/plain; charset=utf-8", false
114 }137 }
115 return "application/octet-stream", true138 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 of15 // 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 ref16 // 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:], "/"), true25 return r, strings.TrimPrefix(p[end:], "/"), true
26 }26 }
27 }27 }
28- return nil, "", false28+ return domain.Ref{}, "", false
29 }29 }
30 30
31 // refsByName indexes the branches and tags by short name. A branch wins over31 // 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 continue38 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()] = r42+ names[r.ShortName] = r
43 }43 }
44 }44 }
45 return names45 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: a71 // 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, or74+ // 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 string76 rev string
77 // name is how the URL spells the ref: a branch or a tag, or the commit id77 // 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 file98 // 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 return104 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 return108 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 the113 // 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 tag114 // 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. An115 // (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 or116 // 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{}, false122 return target{}, false
121 }123 }
122- return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true124+ 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}, true127+ 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 false154 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 true158 return true
157 }159 }
158 }160 }
M internal/adapters/web/refs_test.go +29 -12
@@ -3,27 +3,42 @@
3 package web3 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_TAG33+ 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 string40 path, ref, rest string
26- kind pb.RefKind41+ kind domain.RefKind
27 ok bool42 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 return61 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 each15 // 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 the33 // 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() == def42+ a, b := branches[i].ShortName == def, branches[j].ShortName == def
43 return a && !b43 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()].at50+ 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 p61 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 row75 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 its17 // 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 and21 // 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.Repo24+ repo ports.RepoView
27- branches []*pb.Ref25+ branches []domain.Ref
28- tags []*pb.Ref26+ 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 bool28 cut bool
31 // head is the branch the repository opens on: its default branch, or the29 // 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.Ref31+ 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 invalidly52 // 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, which53+// 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, err62 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 := "", 068 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, err75 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 break87 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 break91 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 = b96+ 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, nil102 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 ref113 // 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, def122 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 whose175 // 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 = 8178 const workers = 8
156 var (179 var (
157 mu sync.Mutex180 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 continue188 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 refCommit206 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.WhenAt209 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] = rc213 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 "" when221 // 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, the238 // 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. The239 // 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 row248 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.instance266 l.Title = d.name() + " · " + s.instance
236 l.Sections = nil267 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 to31 // 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 the33 // 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 accept36 // 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 sibling37 // 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. The46 // 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 r128 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 started145 // 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 string265 var token string
277 var lifetime time.Duration266 var lifetime time.Duration
278- err := ErrLinkRejected267+ 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 return275 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 string26+
24- err error27+// newVerifier is a WebSessionService that accepts the session token valid, checked
25- used []string28+// 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, ErrLinkRejected57+ m := newVerifier(t, valid, nil)
34- }58+ expectLogin(m, valid)
35- return f.valid, time.Hour, nil59+ 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.err63+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, lim82 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, and112 // 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 slash151 // 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.Code170 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 the198 // 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 and228 // 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 page229 // 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- <button96+ <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 <button108 <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 machine1+// 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 reaction3+// 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 the220 // 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 page226 // 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, see300 // ermiteLive applies the fragments of the event stream (/-/events, see
41 // internal/adapters/web/events.go). A fragment is a top-level element with an301 // 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 fragment302 // 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 not303 // 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 may304+// 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; a22 // 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, nil42 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, err54 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 == 060+ 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.instance74 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 = true76 l.HasRail = true
77- l.Lede = d.repo.GetDescription()77+ l.Lede = d.repo.Description
78 return Summary(l, p), http.StatusOK, nil78 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.whenAt95 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 fs110 return fs
111 }111 }
112 112
113 // lastPushFact is the "last push" fact of a repository; the live updates113 // 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 last121 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 string126 var name string
127 var best refCommit127 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(), c131+ name, best = t.ShortName, c
132 }132 }
133 }133 }
134 return name, best134 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, reads143 // 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. Rendering144 // 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, err150 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 ("" for155 // 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 are156 // 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.TreeEntry159+//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 continue166 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, i170 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, nil175 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}, nil180 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, err184 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}, nil188 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 = h194 rd.HTML = h
191 return &renderedReadme{readme: rd, outline: outline}, nil195 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 -- escaped200 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
197 return &renderedReadme{readme: rd}, nil201 return &renderedReadme{readme: rd}, nil
198 }202 }
199 203
200-// readBlob reads a file through the GetBlob stream, at most limit bytes. It204+// 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, err209 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, err217 return nil, false, err
220 }218 }
219+ if int64(len(body)) > limit {
220+ return body[:limit], false, nil
221+ }
221 return body, false, nil222 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, and42 // 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, errNotFound53 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, err57 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.WhenAt81 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, nil90 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, err100 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 URL107 // 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 and7 // 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3 // Package web is the HTML front of ermite: the pages of the read-only3 // 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 the6+// Like every other inbound adapter, the package reads and writes through the
7-// generated ermite-api Connect clients, which cmd binds in process to the7+// 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 use8+// 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, set9+// 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. The10+// (ServeHTTP sets it in the context, principal reads it). The use cases then
11-// API then shows public repositories only; a private repository is11+// 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 in15 // 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. The39 // 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.Second46 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.RepoServiceClient51+ Repos ports.RepoService
51- Git ermiteconnect.GitServiceClient52+ 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.MergeRequestServiceClient55+ // than assigning a typed nil pointer.
55- // Events is the client of the event stream behind /-/events. Without56+ 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.EventServiceClient58+ // 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.Duration63 Heartbeat time.Duration
@@ -69,10 +72,10 @@ type Config struct {
69 Now func() time.Time72 Now func() time.Time
70 // Sessions signs the owner in (ADR 0018). Without it there are no login73 // 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 SessionAuth75+ Sessions ports.WebSessionService
73 // Limiter counts the failed logins; required with Sessions. Share it with76 // 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 FailureLimiter78+ Limiter ports.FailureLimiter
76 // TrustedProxies are the networks allowed to set X-Forwarded-For, to find79 // 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.Prefix81 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 *pathCache92 pathCache *pathCache
90 mux *http.ServeMux93 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 return175 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 into184+// 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 to185 // 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 the253+// 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 visitor254+// 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 to255+// 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 exist271 // 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 return111 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 the697 // 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 authenticate806 // 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, and807+// 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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}}, nil39+ 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}}, nil53+ },
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{}, nil64+ },
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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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.trustedProxies70 env[config.EnvTrustedProxies] = o.trustedProxies
71 }71 }
72- // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite72+ // 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] = r74 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+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.Mutex28+const watchWait = 10 * time.Second
25- revoked map[string]bool29+
26- calls int30+// 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.ErrUnauthenticated75+ w := h.watch(root, "")
35- }76+
36- return nil77+ _, 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] = true86+ 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.calls92+
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.T97+ // Create on first push publishes RepoCreated once, then the push.
57- meta *mocks.RepoMetaStore98+ wt := h.workTree("main", "a.txt")
58- git *mocks.GitRepos99+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main")
59- feed chan domain.Event100+ assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
60- auth *fakeAuth101+ assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo())
61- events *app.Events102+ // 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: it113+ 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 were146+// 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 an154+ 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.VisibilityPrivate173+ 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 = 8178+// 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 []string188+ 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 that226+ 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 = 4243+// 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.Mutex248+ w := h.watch(h.api(""), "", "repo:a")
359- var got []app.WatchEvent249+
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 := true252+ 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 over255+ h.writeFile(wt, "y.txt", []byte("y\n"))
366- first = false256+ 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 priv320+ 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.Mutex374+ topics[i] = "home"
602- repo domain.Repo375+ }
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 was395+// 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 string402+ 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 about404+ 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 in34 // 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 this40 // hookExe is the executable the hook scripts exec; empty means this
41 // process's own executable.41 // process's own executable.
42 hookExe string42 hookExe string
43- // maintenanceDelay overrides app.MaintenanceDelay when positive.43+ // maintenanceDelay overrides usecase.MaintenanceDelay when positive.
44 maintenanceDelay time.Duration44 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 int6446 maxBodyBytes int64
47- // eventBuffer overrides app.DefaultEventBuffer when positive.47+ // eventBuffer overrides usecase.DefaultEventBuffer when positive.
48 eventBuffer int48 eventBuffer int
49- // revalidateEvery overrides app.DefaultRevalidateEvery when not zero.49+ // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
50 revalidateEvery time.Duration50 revalidateEvery time.Duration
51 }51 }
52 52
@@ -56,24 +56,24 @@ type forge struct {
56 store *kvstore.Store56 store *kvstore.Store
57 server *httpserver.Server57 server *httpserver.Server
58 hooks *githttp.HookServer58 hooks *githttp.HookServer
59- maint *app.Maintainer59+ maint *usecase.Maintainer
60 git *gitcli.Git60 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.Adapter62 reader *ermitegit.Adapter
63 63
64 // The use cases and the API are exposed for tests and for the phases64 // 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.Repos66+ Repos *usecase.Repos
67- Tokens *app.Tokens67+ Tokens *usecase.Tokens
68- Auth *app.Auth68+ Auth *usecase.Auth
69- Pushes *app.Pushes69+ Pushes *usecase.Pushes
70- Browse *app.Browse70+ Browse *usecase.Browse
71 // MergeRequests and SigningKey serve MergeRequestService and71 // MergeRequests and SigningKey serve MergeRequestService and
72 // ServerService.GetSigningKey.72 // ServerService.GetSigningKey.
73- MergeRequests *app.MergeRequests73+ MergeRequests *usecase.MergeRequests
74- SigningKey *app.SigningKey74+ SigningKey *usecase.SigningKey
75- Sessions *app.Sessions75+ Sessions *usecase.Sessions
76- EventLog *app.Events76+ EventLog *usecase.Events
77 API *rpc.API77 API *rpc.API
78 Bus ports.EventBus78 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.hookExe158 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.reader181 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, err196 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, err208 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
@@ -43,7 +43,7 @@ func binary(t *testing.T) string {
43 return43 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 once24+ 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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">&lt;svg</span>109 <span class="nt">&lt;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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
291 </a>292 </a>
292 </li>293 </li>
293 <li>294 <li>
294-<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
291 </a>292 </a>
292 </li>293 </li>
293 <li>294 <li>
294-<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f3&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f4&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f5&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : 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(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : 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="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : 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&amp;view=commits&amp;ref=main&amp;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&amp;view=commits&amp;ref=main&amp;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&amp;view=commits&amp;ref=main&amp;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&amp;view=commits&amp;ref=main&amp;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 &amp; 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&amp;dir=asc">name </a>
56+</th>
57+<th class="c-desc" aria-sort="none">
58+<a href="/?sort=desc&amp;dir=asc">description </a>
59+</th>
60+<th class="c-when" aria-sort="descending">
61+<a href="/?sort=push&amp;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&amp;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 &amp; 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 &amp; 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 &amp; 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&amp;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&amp;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&amp;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&amp;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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 generated25+// 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.Server28 // 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 public47 // 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 token77+// warnSharedClientKey tells the operator, at startup, that without trusted
83-// gives the context of the owner, whose principal the API calls of the page78+// 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.ErrLinkRejected104+ return "", 0, ports.ErrLinkRejected
91 }105 }
92 if err != nil {106 if err != nil {
93 return "", 0, err107 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 own110 // 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.SessionTTL113+ 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.ErrLinkRejected118+ return "", 0, ports.ErrLinkRejected
105 }119 }
106 return token, lifetime, nil120 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, nil126+ return domain.Anonymous(), false, nil
113 }127 }
114 if err != nil {128 if err != nil {
115- return ctx, false, err129+ return domain.Anonymous(), false, err
116 }130 }
117- return domain.ContextWithPrincipal(ctx, p), true, nil131+ 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 each21 // 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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.VisibilityPrivate139 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 was292 // 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 still529 // anonymous gets the 404 of a missing repository, as the owner still
530 // reads it.530 // reads it.
531 priv := domain.VisibilityPrivate531 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 branch20 // 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 &lt;b&gt;search&lt;/b&gt;</h1>")133 assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add &lt;b&gt;search&lt;/b&gt;</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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 is509+// 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 loopback527 // 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+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 return100 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 client23 // Limiter defaults: 10 failed authentications in 10 minutes block the client
@@ -54,6 +56,8 @@ type Limiter struct {
54 lastSweep time.Time56 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 string183 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3 // Package config parses and validates the server configuration from the3 // Package config parses and validates the server configuration from the
4-// environment. It is an entrypoint helper: only cmd imports it, and it never4+// 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 config6 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 string87 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 string19 in string
19 valid bool20 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 string73 in string
71 want string74 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.Principal113 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 breaks16 // 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 string21 name string
21 in string22 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 string71 name string
69 in string72 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.Violations124 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-// Package ports declares the interfaces the use cases need from the outside3+// 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 ports12 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+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.RepoMetaStore25 meta *mocks.RepoMetaStore
26 reader *mocks.RepoReader26 reader *mocks.RepoReader
27- browse *app.Browse27+ 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 f34 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 string48 name string
49- call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error49+ 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 err59 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 err63 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 err69 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 err75 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 err81 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 err97 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 err103 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 Git112 // 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.pub142 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 out208 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.Ref254 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 path335 {PageToken: page.NextPageToken}, // no path
325 {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path336 {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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
@@ -38,6 +38,8 @@ type Maintainer struct {
38 wg sync.WaitGroup38 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 idle44 timer *time.Timer // pending run, nil while running or idle
43 running bool45 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-later1 // 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 on3+// 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 app5+package usecase
6 6
7 import (7 import (
8 "context"8 "context"
@@ -23,20 +23,14 @@ type Repos struct {
23 clock ports.Clock23 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 are28 // 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 the34 // 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 failure36 // 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 get40 // 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 }
R internal/app/repos_integration_test.go → internal/usecase/repos_integration_test.go +5 -4 collapsed to keep the page light · renamed, 91% similar
R internal/app/repos_test.go → internal/usecase/repos_test.go +36 -15 collapsed to keep the page light · renamed, 87% similar
R internal/app/repos_write.go → internal/usecase/repos_write.go +7 -26 collapsed to keep the page light · renamed, 87% similar
R internal/app/repos_write_test.go → internal/usecase/repos_write_test.go +61 -13 collapsed to keep the page light · renamed, 79% similar
R internal/app/sessions.go → internal/usecase/sessions.go +8 -13 collapsed to keep the page light · renamed, 93% similar
R internal/app/sessions_test.go → internal/usecase/sessions_test.go +53 -65 collapsed to keep the page light · renamed, 82% similar
R internal/app/signing.go → internal/usecase/signing.go +3 -1 collapsed to keep the page light · renamed, 92% similar
R internal/app/tokens.go → internal/usecase/tokens.go +17 -28 collapsed to keep the page light · renamed, 91% similar
R internal/app/tokens_test.go → internal/usecase/tokens_test.go +93 -44 collapsed to keep the page light · renamed, 78% similar

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close