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"
1010 entrypoint = ["./tmp/ermite", "serve"]
1111 # The web assets are embedded (go:embed static), so they need a rebuild too.
1212 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"]
1414 # *_templ.go is the output of templ generate: a .templ change triggers the
1515 # rebuild, the generated Go file does not trigger a second one.
1616 exclude_regex = ["_test\\.go", "_templ\\.go"]
M .golangci.yml +1 -0
@@ -7,6 +7,7 @@ linters:
77 - ineffassign
88 - misspell
99 - staticcheck
10+ - tparallel
1011 - unconvert
1112 - unused
1213 exclusions:
M .hexcheck.yaml +46 -2
@@ -4,10 +4,10 @@ components:
44 role: core
55 paths:
66 - internal/domain/**
7- app:
7+ usecase:
88 role: usecase
99 paths:
10- - internal/app/**
10+ - internal/usecase/**
1111 ports:
1212 role: ports
1313 paths:
@@ -16,10 +16,23 @@ components:
1616 role: adapter
1717 paths:
1818 - internal/adapters/**
19+ clientlimit:
20+ # Per-client HTTP rate limiting: infrastructure shared by the HTTP adapters.
21+ role: adapter
22+ paths:
23+ - internal/clientlimit/**
1924 entrypoints:
25+ # cmd/ermite only calls app.Run.
2026 role: entrypoint
2127 paths:
2228 - 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/**
2336 config:
2437 role: entrypoint
2538 paths:
@@ -30,8 +43,39 @@ components:
3043 - gen/**
3144 - '**/mocks/**'
3245 - '**/*_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
3365 mocking:
3466 generatedMockPaths:
3567 - internal/ports/mocks/**
3668 generatedMockNamePatterns:
3769 - '{{Interface}}'
70+allow:
71+ # clientlimit is a shared infrastructure library, not a sibling adapter: the
72+ # HTTP adapters that rate-limit clients import it by design.
73+ - rule: no-adapter-to-adapter-imports
74+ path: internal/adapters/githttp/**
75+ reason: rate-limits Git clients with internal/clientlimit
76+ - rule: no-adapter-to-adapter-imports
77+ path: internal/adapters/rpc/**
78+ reason: rate-limits API clients with internal/clientlimit
79+ - rule: no-adapter-to-adapter-imports
80+ path: internal/adapters/web/**
81+ reason: rate-limits login attempts with internal/clientlimit
M .mockery.yml +10 -19
@@ -1,22 +1,13 @@
1-# Mocks of the ports. Regenerate with: go generate ./internal/ports
1+# Mocks of the ports, generated in internal/ports/mocks. Regenerate with:
2+# go generate ./internal/ports
23 template: testify
4+_anchors:
5+ mocks: &mocks
6+ dir: "{{.InterfaceDir}}/mocks"
7+ pkgname: mocks
8+ filename: "{{.InterfaceName}}.go"
9+ structname: "{{.InterfaceName}}"
10+ all: true
311 packages:
412 bnema.dev/ermite/internal/ports:
5- config:
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:
13+ config: *mocks
M Dockerfile +1 -1
@@ -24,7 +24,7 @@ COPY . .
2424 ARG VERSION=
2525 RUN --mount=type=cache,target=/go/pkg/mod \
2626 --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}" \
2828 -o /out/ermite ./cmd/ermite
2929
3030 FROM alpine:3.24
M README.md +12 -0
@@ -98,6 +98,18 @@ go build ./cmd/ermite
9898 go test ./...
9999 ```
100100
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+
101113 ## Develop
102114
103115 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
66 import (
77 "fmt"
88 "os"
9- "runtime/debug"
9+
10+ "bnema.dev/ermite/internal/app"
1011 )
1112
1213 func main() {
13- if err := newRootCmd().Execute(); err != nil {
14+ if err := app.Run(os.Args[1:]); err != nil {
1415 fmt.Fprintln(os.Stderr, "ermite:", err)
15- os.Exit(1)
16+ os.Exit(app.ExitCode(err))
1617 }
1718 }
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 (
1111 "path/filepath"
1212 "sync"
1313 "testing"
14+ "testing/synctest"
1415 "time"
1516
1617 "github.com/bnema/zerowrap"
@@ -207,56 +208,129 @@ func TestEncodedCommitIsServedByGit(t *testing.T) {
207208 assert.Equal(t, "café", got.Commits[0].Message)
208209 }
209210
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+
210219 // A repository that nobody reads for IdleTTL is closed, without a goroutine
211220 // per repository.
212221 func TestIdleRepositoriesAreClosed(t *testing.T) {
213- ctx := context.Background()
214- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond })
215- f := commitOf(t, data, "idle")
216- _, err := a.ListRefs(ctx, f.Name)
217- require.NoError(t, err)
218- require.Equal(t, 1, a.OpenRepos())
219- require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond)
222+ synctest.Test(t, func(t *testing.T) {
223+ ctx := context.Background()
224+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
225+ f := commitOf(t, data, "idle")
226+ _, err := a.ListRefs(ctx, f.Name)
227+ require.NoError(t, err)
228+ require.Equal(t, 1, a.OpenRepos())
220229
221- // It opens again on the next read.
222- _, err = a.ListRefs(ctx, f.Name)
223- require.NoError(t, err)
224- assert.Equal(t, 1, a.OpenRepos())
230+ synctest.Sleep(idleTTL) // idle for exactly the TTL: not more
231+ assert.Equal(t, 1, a.OpenRepos(), "not idle for more than the TTL yet")
232+ synctest.Sleep(idleTTL/4 - time.Nanosecond)
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+ })
225259 }
226260
227261 // A Refresh after a write is not a read: a repository pushed to but never read
228262 // still closes after IdleTTL.
229263 func TestRefreshIsNotAUse(t *testing.T) {
230- ctx := context.Background()
231- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond })
232- f := commitOf(t, data, "pushed")
233- _, err := a.ListRefs(ctx, f.Name)
234- require.NoError(t, err)
235- start := time.Now()
236- for time.Since(start) < 5*time.Second && a.OpenRepos() == 1 {
237- a.Refresh(ctx, f.Name)
238- time.Sleep(20 * time.Millisecond)
239- }
240- assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open")
264+ synctest.Test(t, func(t *testing.T) {
265+ ctx := context.Background()
266+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
267+ f := commitOf(t, data, "pushed")
268+ _, err := a.ListRefs(ctx, f.Name)
269+ require.NoError(t, err)
270+ // A push every 30 seconds, until the sweep that follows the TTL.
271+ for elapsed := time.Duration(0); elapsed < idleTTL+idleTTL/4; elapsed += 30 * time.Second {
272+ require.Equal(t, 1, a.OpenRepos(), "open after %v", elapsed)
273+ a.Refresh(ctx, f.Name)
274+ synctest.Sleep(30 * time.Second)
275+ }
276+ assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open")
277+ })
241278 }
242279
243280 // The idle close does not cut a blob stream the reader still holds: the library
244281 // keeps the stream valid after Close.
245282 func TestIdleCloseDoesNotCutAStream(t *testing.T) {
246- ctx := context.Background()
247- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 50 * time.Millisecond })
248- f := readertest.NewFixture(t, data, "stream")
249- content := readertest.RandomBytes(3<<20, 12)
250- f.WriteBytes("big.bin", content)
251- f.Commit("big")
283+ synctest.Test(t, func(t *testing.T) {
284+ ctx := context.Background()
285+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
286+ f := readertest.NewFixture(t, data, "stream")
287+ content := readertest.RandomBytes(3<<20, 12)
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+}
252301
253- _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))
254- require.NoError(t, err)
255- require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond)
256- got, err := io.ReadAll(rc)
257- require.NoError(t, err)
258- require.NoError(t, rc.Close())
259- assert.True(t, bytes.Equal(content, got))
302+// A repository ermite-git could not read is served by git for UnsupportedRetry,
303+// then opened again: a repack, or the removal of a replace ref, can make it
304+// readable. The commit-graph of the history fixture is mapped only while the
305+// library has the repository open.
306+func TestUnsupportedRepositoryIsTriedAgainAfterTheRetryDelay(t *testing.T) {
307+ synctest.Test(t, func(t *testing.T) {
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+ })
260334 }
261335
262336 // 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 {
8787 ResolveRefPath Backend
8888 // GetTree is a directory listing without last commits.
8989 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.
9192 GetTreeLastCommits Backend
92- OpenBlob Backend
93+ // GetTreeSubdirLastCommits is the same for a subdirectory, which costs a
94+ // much longer history walk (195 ms with git on lazygit).
95+ GetTreeSubdirLastCommits Backend
96+ OpenBlob Backend
9397 // ListCommits is a page of history of a revision.
9498 ListCommits Backend
9599 // ListCommitsPath is a page of the history of a file or a directory.
@@ -98,10 +102,22 @@ type Routes struct {
98102 ListPaths Backend
99103 }
100104
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+
101117 // AllRoutes returns routes that send every method to b. The tests and the
102118 // benchmarks use it to exercise a backend whatever the production routes are.
103119 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}
105121 }
106122
107123 // Config configures an Adapter.
@@ -335,11 +351,7 @@ func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domai
335351 // GetTree lists a directory.
336352 func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
337353 cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) }
338- route := a.routes.GetTree
339- if includeLastCommit {
340- route = a.routes.GetTreeLastCommits
341- }
342- if route == CLI {
354+ if a.routes.tree(path, includeLastCommit) == CLI {
343355 return cli()
344356 }
345357 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
44
55 // DefaultRoutes returns the routes of production.
66 //
7-// The rule: a method is routed to ermite-git only when it passes the shared
7+// The rule: a method is routed to ermite-git when it passes the shared
88 // conformance table (TestConformance, TestCompareWithCLI) and its benchmark
9-// beats the CLI adapter in time, in bytes AND in allocations per call, warm
10-// AND cold, on the generated fixture AND on lazygit (BenchmarkRepoReader,
11-// ERMITE_GIT_BENCH_REPO). Where a method has several shapes, every shape must
12-// win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and
13-// ListCommitsPath cover directories of any size and files of any history. A
14-// method that does not win stays on the CLI. GetCommit is not routable: the
15-// diff stays on git. ListPaths has one shape: the fixture lists 131 files and
16-// lazygit 2,180.
9+// beats the CLI adapter WARM in time AND in bytes per call, on the generated
10+// fixture AND on lazygit (BenchmarkRepoReader, ERMITE_GIT_BENCH_REPO), and its
11+// COLD run does not lose in time by more than the open itself (about 65 µs,
12+// paid once per open, see below). Allocation counts are reported but do not
13+// decide: the repository cache (cache.go) keeps up to 64 repositories open for
14+// ten minutes, so nearly every request is warm, and the CLI's own cost (a
15+// process per call, absent from the Go counters) is not in its numbers. Where
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.
1721 //
1822 // Warm is a repository that is open; cold closes it before every call, the
1923 // price of the first read after the idle TTL, an eviction or a restart (a
@@ -97,45 +101,53 @@ package ermitegit
97101 // ListCommits/dirpath lazygit warm 6.7ms 127.8K 403 2.0ms 50.2K 181 win
98102 // ListCommits/dirpath lazygit cold 6.7ms 127.8K 403 3.3ms 887.1K 2019 loses ba
99103 //
100-// What decides the routes, with ermite-git 5228465 (zlib reader with a reused
101-// adler32, header-size cache, Refresh and LookupRefID without allocations):
104+// The "verdict" column is the strict reading, every line of the table won;
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):
102108 //
103-// - The warm runs all win but GetTree of a large directory on lazygit (time
104-// is won since the zlib change, bytes are not) and OpenBlob, whose
105-// allocations come from the inflate of a small blob and whose time is
106-// lost for a 10 MiB blob (inflate speed: 52 ms against 21 ms).
107-// - The cold runs lose in allocations for every method that does little
108-// work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200
109-// against 60 to 140), and in time too for the ones that cost a few
110-// microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs).
111-// - Only the history of a revision (ListCommits page 1 and deep) and the
112-// recursive path list (ListPaths) win every line, so they are the only
113-// methods routed. ListPaths wins even cold because one walk replaces a
114-// process and the parsing of its output (the bytes of the paths are kept in
115-// 32 KiB blocks, which is what brought the lazygit bytes under the CLI's:
116-// a single growing buffer lost them, 699K against 433K). GetTree dir+last
117-// also wins every line, but GetTreeLastCommits covers GetTree root+last
118-// too, which loses cold allocations on lazygit (4204 against 3073): it is
119-// the nearest miss, and the first to flip once opening a repository
120-// allocates less.
121-// - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit,
122-// warm bytes and allocations; above, it loses time. No size makes it win,
123-// so there is no size-based route.
124-//
125-// Once opening a repository allocates less, re-run the benchmark and flip the
126-// constants it wins: ListRefs, ResolveRefPath and ResolveRevision win warm by 4
127-// to 60 times in time.
109+// - ListRefs (every repository page, through loadRepo): 905 µs against 24 µs
110+// warm, 120 KiB against 19 KiB (fixture), 960 against 16 µs and 108 KiB
111+// against 2 KiB (lazygit). Cold it is still 4 to 10 times faster
112+// (93 µs); only its allocation count loses. Routed.
113+// - ResolveRevision (name, full id, abbreviation) and ResolveRefPath: 4 to 8
114+// times faster warm, with fewer bytes. Cold they lose 45 µs of time to the
115+// open, but the ListRefs of the same page has opened the repository
116+// already, so the open is paid by a call that wins it back. Routed.
117+// - GetTree with last commits: root listing 4.1 ms against 68 µs on the
118+// fixture, 20.5 ms against 6.2 ms on lazygit (cold 8.7 ms; its 4,206
119+// allocations against 3,082 are the only line lost); a subdirectory of
120+// lazygit 195-199 ms against 8-9 ms warm, 15-17 ms cold, 20 MiB against
121+// 1.5 MiB. They are two constants so that either can be flipped back on
122+// its own. Both routed.
123+// - GetTree without last commits wins warm in time everywhere (13 µs against
124+// 0.7 ms at the root), but a large directory of lazygit loses warm bytes
125+// (325 KiB against 275 KiB) and, cold, 4.0 ms against 1.4 ms in time. It
126+// stays on the CLI.
127+// - ListCommits of a revision wins every line and stays routed.
128+// ListCommitsPath wins warm and cold in time (lazygit file: 12.9 ms against
129+// 6.0 ms warm), only its cold bytes lose (2.4 MiB): it is a candidate, not
130+// flipped here, to be decided with its own conformance review.
131+// - OpenBlob by size: below 1 MiB the small blob loses warm bytes and
132+// allocations on lazygit; above, it loses time (inflate speed: 52 ms
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.
128139 func DefaultRoutes() Routes {
129140 return Routes{
130- ListRefs: CLI,
131- ResolveRevision: CLI,
132- ResolveRevisionAbbrev: CLI,
133- ResolveRefPath: CLI,
134- GetTree: CLI,
135- GetTreeLastCommits: CLI,
136- OpenBlob: CLI,
137- ListCommits: ErmiteGit,
138- ListCommitsPath: CLI,
139- ListPaths: ErmiteGit,
141+ ListRefs: ErmiteGit,
142+ ResolveRevision: ErmiteGit,
143+ ResolveRevisionAbbrev: ErmiteGit,
144+ ResolveRefPath: ErmiteGit,
145+ GetTree: CLI,
146+ GetTreeLastCommits: ErmiteGit,
147+ GetTreeSubdirLastCommits: ErmiteGit,
148+ OpenBlob: CLI,
149+ ListCommits: ErmiteGit,
150+ ListCommitsPath: CLI,
151+ ListPaths: ErmiteGit,
140152 }
141153 }
M internal/adapters/eventbus/bus_test.go +105 -85
@@ -4,9 +4,10 @@ package eventbus_test
44
55 import (
66 "context"
7+ "runtime"
78 "sync"
89 "testing"
9- "time"
10+ "testing/synctest"
1011
1112 "github.com/stretchr/testify/assert"
1213 "github.com/stretchr/testify/require"
@@ -19,121 +20,140 @@ func ev(ref string) domain.Event {
1920 return domain.PushEvent{Updates: []domain.RefUpdate{{Ref: ref}}}
2021 }
2122
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.
2225 func recv(t *testing.T, ch <-chan domain.Event) domain.Event {
2326 t.Helper()
27+ synctest.Wait()
2428 select {
2529 case e, ok := <-ch:
2630 require.True(t, ok, "channel closed")
2731 return e
28- case <-time.After(2 * time.Second):
32+ default:
2933 t.Fatal("no event")
3034 return nil
3135 }
3236 }
3337
3438 func TestFanOut(t *testing.T) {
35- ctx, cancel := context.WithCancel(context.Background())
36- defer cancel()
37- b := eventbus.New()
38- a, c := b.Subscribe(ctx), b.Subscribe(ctx)
39+ t.Parallel()
40+ synctest.Test(t, func(t *testing.T) {
41+ ctx, cancel := context.WithCancel(context.Background())
42+ defer cancel()
43+ b := eventbus.New()
44+ a, c := b.Subscribe(ctx), b.Subscribe(ctx)
3945
40- b.Publish(ctx, ev("1"))
41- b.Publish(ctx, domain.RepoCreated{})
42- assert.Equal(t, ev("1"), recv(t, a))
43- assert.Equal(t, ev("1"), recv(t, c))
44- assert.Equal(t, "repo.created", recv(t, a).EventKind())
45- assert.Equal(t, "repo.created", recv(t, c).EventKind())
46+ b.Publish(ctx, ev("1"))
47+ b.Publish(ctx, domain.RepoCreated{})
48+ assert.Equal(t, ev("1"), recv(t, a))
49+ assert.Equal(t, ev("1"), recv(t, c))
50+ assert.Equal(t, "repo.created", recv(t, a).EventKind())
51+ assert.Equal(t, "repo.created", recv(t, c).EventKind())
52+ })
4653 }
4754
4855 func TestOnlyEventsFromSubscription(t *testing.T) {
49- ctx, cancel := context.WithCancel(context.Background())
50- defer cancel()
51- b := eventbus.New()
52- b.Publish(ctx, ev("before"))
53- ch := b.Subscribe(ctx)
54- b.Publish(ctx, ev("after"))
55- assert.Equal(t, ev("after"), recv(t, ch))
56+ t.Parallel()
57+ synctest.Test(t, func(t *testing.T) {
58+ ctx, cancel := context.WithCancel(context.Background())
59+ defer cancel()
60+ b := eventbus.New()
61+ b.Publish(ctx, ev("before"))
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+ })
5668 }
5769
5870 func TestSlowSubscriberGetsGapAndDoesNotBlock(t *testing.T) {
59- ctx, cancel := context.WithCancel(context.Background())
60- defer cancel()
61- b := eventbus.NewWithBuffer(4)
62- slow, fast := b.Subscribe(ctx), b.Subscribe(ctx)
71+ t.Parallel()
72+ synctest.Test(t, func(t *testing.T) {
73+ ctx, cancel := context.WithCancel(context.Background())
74+ defer cancel()
75+ b := eventbus.NewWithBuffer(4)
76+ slow, fast := b.Subscribe(ctx), b.Subscribe(ctx)
6377
64- done := make(chan struct{})
65- go func() {
66- defer close(done)
78+ // Publish runs in this goroutine: one that blocked on the slow
79+ // subscriber would deadlock the bubble and fail the test.
6780 for i := range 20 {
6881 b.Publish(ctx, ev(string(rune('a'+i))))
6982 // The fast subscriber keeps up.
70- recv(t, fast)
83+ assert.Equal(t, ev(string(rune('a'+i))), recv(t, fast))
7184 }
72- }()
73- select {
74- case <-done:
75- case <-time.After(5 * time.Second):
76- t.Fatal("Publish blocked on a slow subscriber")
77- }
7885
79- // The slow one lost events: it sees a gap marker, then only recent events.
80- var got []domain.Event
81- for len(slow) > 0 {
82- 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
86+ // The slow one lost events: it sees a gap marker, then only recent events.
87+ var got []domain.Event
88+ for len(slow) > 0 {
89+ got = append(got, <-slow)
8990 }
90- }
91- assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered")
92- assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered")
93- assert.LessOrEqual(t, len(got), 4)
91+ require.NotEmpty(t, got)
92+ var gapAt = -1
93+ for i, e := range got {
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+ })
94102 }
95103
96104 func TestUnsubscribeOnContext(t *testing.T) {
97- b := eventbus.New()
98- ctx, cancel := context.WithCancel(context.Background())
99- ch := b.Subscribe(ctx)
100- require.Equal(t, 1, b.Subscribers())
101- cancel()
102- select {
103- case _, ok := <-ch:
104- assert.False(t, ok)
105- case <-time.After(2 * time.Second):
106- t.Fatal("channel not closed")
107- }
108- assert.Equal(t, 0, b.Subscribers())
109- b.Publish(context.Background(), ev("x")) // no panic
105+ t.Parallel()
106+ synctest.Test(t, func(t *testing.T) {
107+ b := eventbus.New()
108+ ctx, cancel := context.WithCancel(context.Background())
109+ ch := b.Subscribe(ctx)
110+ require.Equal(t, 1, b.Subscribers())
111+ synctest.Wait()
112+ require.Equal(t, 1, b.Subscribers(), "still subscribed while ctx lives")
113+ cancel()
114+ synctest.Wait() // the AfterFunc of the context has run
115+ select {
116+ case _, ok := <-ch:
117+ assert.False(t, ok)
118+ default:
119+ t.Fatal("channel not closed")
120+ }
121+ assert.Equal(t, 0, b.Subscribers())
122+ b.Publish(context.Background(), ev("x")) // no panic
123+ })
110124 }
111125
112126 func TestConcurrentPublishSubscribe(t *testing.T) {
113- b := eventbus.NewWithBuffer(8)
114- ctx, cancel := context.WithCancel(context.Background())
115- var wg sync.WaitGroup
116- for range 4 {
117- wg.Add(2)
118- go func() {
119- defer wg.Done()
120- for range 200 {
121- b.Publish(ctx, ev("x"))
122- }
123- }()
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):
127+ t.Parallel()
128+ synctest.Test(t, func(t *testing.T) {
129+ b := eventbus.NewWithBuffer(8)
130+ ctx, cancel := context.WithCancel(context.Background())
131+ var wg sync.WaitGroup
132+ for range 4 {
133+ wg.Add(2)
134+ go func() {
135+ defer wg.Done()
136+ for range 200 {
137+ b.Publish(ctx, ev("x"))
132138 }
133- }
134- scancel()
135- }()
136- }
137- wg.Wait()
138- cancel()
139+ }()
140+ go func() {
141+ defer wg.Done()
142+ sctx, scancel := context.WithCancel(ctx)
143+ ch := b.Subscribe(sctx)
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+ })
139159 }
M internal/adapters/gitcli/backend.go +7 -0
@@ -32,6 +32,8 @@ func HooksDir(dataDir string) string { return filepath.Join(dataDir, "run", "hoo
3232 // core.hooksPath=<data>/run/hooks
3333 // receive.fsckObjects=true
3434 // uploadpack.allowFilter=true
35+// pack.threads=1
36+// pack.windowMemory=256m
3537 // http.receivepack=true (push only)
3638 // receive.maxInputSize=<max> (push only, when configured)
3739 //
@@ -45,6 +47,11 @@ func (g *Git) HTTPBackend(ctx context.Context, push bool) (ports.HTTPBackend, er
4547 {"core.hooksPath", HooksDir(g.dataDir)},
4648 {"receive.fsckObjects", "true"},
4749 {"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"},
4855 }
4956 if push {
5057 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) {
8686 "core.hooksPath": filepath.Join(data, "run", "hooks"),
8787 "receive.fsckObjects": "true",
8888 "uploadpack.allowFilter": "true",
89+ "pack.threads": "1",
90+ "pack.windowMemory": "256m",
8991 }, backendConf(e))
9092
9193 push, err := g.HTTPBackend(ctx, true)
@@ -96,6 +98,8 @@ func TestHTTPBackendEnvironment(t *testing.T) {
9698 "core.hooksPath": filepath.Join(data, "run", "hooks"),
9799 "receive.fsckObjects": "true",
98100 "uploadpack.allowFilter": "true",
101+ "pack.threads": "1",
102+ "pack.windowMemory": "256m",
99103 "http.receivepack": "true",
100104 }, backendConf(pe), "http.receivepack only for a push; no size limit unless configured")
101105
M internal/adapters/gitcli/catfile.go +175 -67
@@ -11,6 +11,7 @@ import (
1111 "io"
1212 "os/exec"
1313 "path/filepath"
14+ "slices"
1415 "strconv"
1516 "strings"
1617 "sync"
@@ -36,27 +37,40 @@ func (e *procError) Unwrap() error { return e.err }
3637 const (
3738 defaultIdleTimeout = 60 * time.Second
3839 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
3943 )
4044
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.
4247 //
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.
4453 // - A process nobody used for the idle timeout is killed.
45-// - At most max processes are kept. Starting another one first evicts the
46-// least recently used idle process; a process that is in use is never
47-// evicted, so the bound is soft when more than max repositories are read
48-// at the same moment.
54+// - At most max processes are kept, all repositories together. Starting
55+// another one first evicts the least recently used idle process; a
56+// process that is in use is never evicted. When none can be evicted, a
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.
4960 // - A process that fails, or whose request was cancelled (its output is then
5061 // in an unknown state), is killed and dropped; the next request starts a
5162 // fresh one, and a request that hit a dead process is retried once.
5263 type catPool struct {
53- bin string
54- env []string
55- idle time.Duration
56- max int
64+ bin string
65+ env []string
66+ idle time.Duration
67+ max int // processes, all repositories together
68+ perRepo int // processes of one repository
5769
5870 mu sync.Mutex
59- procs map[string]*catProc
71+ procs map[string][]*catProc // by repository directory
72+ total int // live processes in procs
73+ waiters map[string][]chan *catProc
6074 streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob
6175 closed bool
6276 wg sync.WaitGroup // reapers of started processes
@@ -71,11 +85,10 @@ type catProc struct {
7185 in io.WriteCloser
7286 out *bufio.Reader
7387 stderr *tailBuffer
74- sem chan struct{} // capacity 1: the request in flight
7588
76- broken bool // set by the holder of sem on a protocol error
89+ broken bool // set by the holder on a protocol error
7790
78- users int // requests waiting for or holding sem
91+ busy bool // a request holds the process
7992 lastUsed time.Time
8093 timer *time.Timer
8194 gen uint64
@@ -90,13 +103,14 @@ func newCatPool(bin string, env []string, idle time.Duration, maxProcs int) *cat
90103 maxProcs = defaultMaxProcesses
91104 }
92105 return &catPool{
93- bin: bin, env: env, idle: idle, max: maxProcs,
94- procs: make(map[string]*catProc),
106+ bin: bin, env: env, idle: idle, max: maxProcs, perRepo: min(maxProcsPerRepo, maxProcs),
107+ procs: make(map[string][]*catProc),
108+ waiters: make(map[string][]chan *catProc),
95109 streams: make(map[*blobStream]struct{}),
96110 }
97111 }
98112
99-// do runs fn with exclusive use of the cat-file process of the repository in
113+// do runs fn with exclusive use of a cat-file process of the repository in
100114 // dir, starting it if needed. fn must not keep the connection after returning.
101115 func (p *catPool) do(ctx context.Context, dir string, fn func(*catConn) error) error {
102116 for attempt := 0; ; attempt++ {
@@ -132,7 +146,6 @@ func (p *catPool) run(ctx context.Context, cp *catProc, fn func(*catConn) error)
132146 err = ctx.Err()
133147 }
134148 p.release(cp, cp.broken || cancelled)
135- <-cp.sem
136149 return err
137150 }
138151
@@ -142,58 +155,149 @@ func (p *catPool) isClosed() bool {
142155 return p.closed
143156 }
144157
158+// acquire returns a process of dir that no other request uses.
145159 func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) {
146160 for {
161+ if err := ctx.Err(); err != nil {
162+ return nil, err
163+ }
147164 p.mu.Lock()
148165 if p.closed {
149166 p.mu.Unlock()
150167 return nil, ErrClosed
151168 }
152- cp := p.procs[dir]
153- if cp == nil {
154- var err error
155- if cp, err = p.startLocked(ctx, dir); err != nil {
156- p.mu.Unlock()
157- return nil, err
158- }
169+ cp, wait, err := p.takeLocked(ctx, dir)
170+ if err != nil {
171+ p.mu.Unlock()
172+ return nil, err
159173 }
160- cp.users++
161- cp.gen++
162- if cp.timer != nil {
163- cp.timer.Stop()
164- cp.timer = nil
174+ if cp != nil {
175+ p.mu.Unlock()
176+ return cp, nil
165177 }
178+ p.waiters[dir] = append(p.waiters[dir], wait)
166179 p.mu.Unlock()
167180
168181 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
170193 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+ }
172204 return nil, ctx.Err()
173205 }
174- p.mu.Lock()
175- dead := cp.dead
176- p.mu.Unlock()
177- if !dead {
178- return cp, nil
206+ }
207+}
208+
209+// takeLocked marks an idle process of dir busy and returns it, starting one
210+// when none is idle and the repository may have more. Otherwise it returns the
211+// channel to wait on.
212+func (p *catPool) takeLocked(ctx context.Context, dir string) (*catProc, chan *catProc, error) {
213+ var best *catProc // the most recently used idle one, so that the others idle out
214+ for _, c := range p.procs[dir] {
215+ if !c.busy && (best == nil || c.lastUsed.After(best.lastUsed)) {
216+ best = c
179217 }
180- // Dropped while we waited for our turn.
181- <-cp.sem
182- p.release(cp, false)
183218 }
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
184238 }
185239
186-// release ends one use of cp. A broken process is dropped; an unused one
187-// starts its idle countdown.
240+// makeRoomLocked evicts least recently used idle processes until another one
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.
188280 func (p *catPool) release(cp *catProc, broken bool) {
189281 p.mu.Lock()
190282 defer p.mu.Unlock()
191- cp.users--
192- if broken {
283+ if broken || cp.dead {
284+ cp.busy = false
193285 p.dropLocked(cp)
194286 return
195287 }
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 {
197301 cp.lastUsed = time.Now()
198302 gen := cp.gen
199303 cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) })
@@ -204,7 +308,7 @@ func (p *catPool) release(cp *catProc, broken bool) {
204308 func (p *catPool) expire(cp *catProc, gen uint64) {
205309 p.mu.Lock()
206310 defer p.mu.Unlock()
207- if cp.gen != gen || cp.users != 0 || cp.dead {
311+ if cp.gen != gen || cp.busy || cp.dead {
208312 return
209313 }
210314 cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping")
@@ -212,14 +316,24 @@ func (p *catPool) expire(cp *catProc, gen uint64) {
212316 }
213317
214318 // 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.
216320 func (p *catPool) dropLocked(cp *catProc) {
217321 if cp.dead {
218322 return
219323 }
220324 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 {
222334 delete(p.procs, cp.dir)
335+ } else {
336+ p.procs[cp.dir] = procs
223337 }
224338 if cp.timer != nil {
225339 cp.timer.Stop()
@@ -227,22 +341,13 @@ func (p *catPool) dropLocked(cp *catProc) {
227341 }
228342 _ = cp.in.Close()
229343 _ = cp.cmd.Process.Kill()
344+ for _, w := range p.waiters[cp.dir] {
345+ w <- nil
346+ }
347+ delete(p.waiters, cp.dir)
230348 }
231349
232350 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- }
246351 cmd := exec.Command(p.bin, "--git-dir="+dir, "--no-replace-objects", "--no-optional-locks", "cat-file", "--batch-command="+catFormat, "-Z")
247352 cmd.Env = p.env
248353 cmd.Dir = filepath.Dir(dir)
@@ -261,9 +366,10 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error)
261366 }
262367 cp := &catProc{
263368 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(),
265370 }
266- p.procs[dir] = cp
371+ p.procs[dir] = append(p.procs[dir], cp)
372+ p.total++
267373 p.wg.Add(1)
268374 go func() {
269375 defer p.wg.Done()
@@ -273,11 +379,11 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error)
273379 return cp, nil
274380 }
275381
276-// forget kills the process of the repository in dir, if any.
382+// forget kills the processes of the repository in dir, if any.
277383 func (p *catPool) forget(dir string) {
278384 p.mu.Lock()
279385 defer p.mu.Unlock()
280- if cp := p.procs[dir]; cp != nil {
386+ for _, cp := range slices.Clone(p.procs[dir]) {
281387 p.dropLocked(cp)
282388 }
283389 }
@@ -287,8 +393,10 @@ func (p *catPool) forget(dir string) {
287393 func (p *catPool) close(ctx context.Context) error {
288394 p.mu.Lock()
289395 p.closed = true
290- for _, cp := range p.procs {
291- p.dropLocked(cp)
396+ for _, procs := range p.procs {
397+ for _, cp := range slices.Clone(procs) {
398+ p.dropLocked(cp)
399+ }
292400 }
293401 streams := make([]*blobStream, 0, len(p.streams))
294402 for s := range p.streams {
M internal/adapters/gitcli/export_test.go +40 -7
@@ -2,7 +2,12 @@
22
33 package gitcli
44
5-import "sort"
5+import (
6+ "context"
7+ "sort"
8+
9+ "bnema.dev/ermite/internal/domain"
10+)
611
712 // CatFilePIDs returns the process ids of the live long-lived cat-file
813 // processes, for the tests of the pool.
@@ -10,8 +15,10 @@ func (g *Git) CatFilePIDs() []int {
1015 g.cat.mu.Lock()
1116 defer g.cat.mu.Unlock()
1217 var pids []int
13- for _, cp := range g.cat.procs {
14- pids = append(pids, cp.cmd.Process.Pid)
18+ for _, procs := range g.cat.procs {
19+ for _, cp := range procs {
20+ pids = append(pids, cp.cmd.Process.Pid)
21+ }
1522 }
1623 sort.Ints(pids)
1724 return pids
@@ -44,8 +51,10 @@ func (g *Git) FireIdleTimers() {
4451 gen uint64
4552 }
4653 var all []armed
47- for _, cp := range g.cat.procs {
48- all = append(all, armed{cp, cp.gen})
54+ for _, procs := range g.cat.procs {
55+ for _, cp := range procs {
56+ all = append(all, armed{cp, cp.gen})
57+ }
4958 }
5059 g.cat.mu.Unlock()
5160 for _, a := range all {
@@ -62,8 +71,10 @@ func (g *Git) FireStaleIdleTimers() {
6271 gen uint64
6372 }
6473 var all []armed
65- for _, cp := range g.cat.procs {
66- all = append(all, armed{cp, cp.gen - 1})
74+ for _, procs := range g.cat.procs {
75+ for _, cp := range procs {
76+ all = append(all, armed{cp, cp.gen - 1})
77+ }
6778 }
6879 g.cat.mu.Unlock()
6980 for _, a := range all {
@@ -74,3 +85,25 @@ func (g *Git) FireStaleIdleTimers() {
7485 // ExecSlots returns the size of the one-shot process semaphore and how many
7586 // slots are taken.
7687 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) {
177177 assert.NotEqual(t, old, now)
178178 }
179179
180-func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
180+func TestConcurrentReadsAreCorrectAndBounded(t *testing.T) {
181181 ctx := context.Background()
182182 g, data := newReader(t, nil)
183183 h := readertest.NewHistory(t, data)
@@ -223,7 +223,8 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
223223 for err := range errs {
224224 require.NoError(t, err)
225225 }
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")
227228 }
228229
229230 func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
@@ -406,3 +407,166 @@ func TestBlobStreamsAreBounded(t *testing.T) {
406407 require.NoError(t, c.Close())
407408 assert.Zero(t, g.BlobStreams())
408409 }
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 @@
77 package githttp
88
99 import (
10+ "cmp"
1011 "context"
1112 "errors"
13+ "io"
1214 "log"
1315 "math"
1416 "net/http"
1517 "net/http/cgi"
1618 "net/netip"
1719 "regexp"
20+ "runtime"
1821 "strconv"
1922 "strings"
23+ "sync"
24+ "time"
2025
2126 "github.com/bnema/zerowrap"
2227
@@ -56,59 +61,63 @@ const (
5661 serviceReceive = "git-receive-pack"
5762 )
5863
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-
9064 // Config configures a Handler.
9165 type Config struct {
92- Auth Authenticator
93- Repos Repos
94- Pushes Pushes
66+ Auth ports.AuthService
67+ Repos ports.RepoLookup
68+ Pushes ports.PushService
9569 Backend ports.GitBackend
9670 // Hooks provides hook tokens for pushes. It is required.
97- Hooks HookTokens
71+ Hooks ports.HookTokens
9872 // TrustedProxies are the networks allowed to set X-Forwarded-For.
9973 TrustedProxies []netip.Prefix
10074 // Limiter counts failed authentications; nil builds a default one.
101- Limiter *clientlimit.Limiter
75+ Limiter ports.FailureLimiter
10276 // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes.
10377 // The body of a fetch is bounded by 64 MiB at most.
10478 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
10589 }
10690
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+
107110 // Handler serves the smart HTTP routes of Patterns.
108111 type Handler struct {
109112 cfg Config
110- limiter *clientlimit.Limiter
113+ limiter ports.FailureLimiter
111114 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
112121 }
113122
114123 // New builds a Handler.
@@ -120,6 +129,11 @@ func New(cfg Config) *Handler {
120129 if h.maxBody <= 0 {
121130 h.maxBody = DefaultMaxBodyBytes
122131 }
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)
123137 return h
124138 }
125139
@@ -168,6 +182,8 @@ func parseRoute(r *http.Request) (route, int) {
168182 }
169183
170184 // ServeHTTP implements http.Handler.
185+//
186+//hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases'
171187 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172188 var rt route
173189 var name domain.RepoName
@@ -225,7 +241,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
225241 return
226242 }
227243
228- h.serveBackend(w, r, rt, name, principal)
244+ h.serveBackend(w, r, rt, name, principal, clientlimit.Key(addr))
229245 }
230246
231247 // authenticate reads the credentials of the request. No credentials is the
@@ -311,11 +327,66 @@ var inheritedEnv = []string{
311327 "LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=",
312328 }
313329
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+
314377 // 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) {
316381 ctx := r.Context()
317382 logger := zerowrap.FromCtx(ctx)
318383
384+ release, ok := h.acquire(ctx, w, rt, client)
385+ if !ok {
386+ return
387+ }
388+ defer release()
389+
319390 be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push())
320391 if err != nil {
321392 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,
324395 }
325396 env := append(append([]string(nil), be.Env...), inheritedEnv...)
326397 if rt.suffix == serviceReceive {
327- token, release := h.cfg.Hooks.Register(ctx, name, p)
328- defer release()
398+ token, revoke := h.cfg.Hooks.Register(ctx, name, p)
399+ defer revoke()
329400 env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token)
330401 }
331402
@@ -383,7 +454,16 @@ func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt rout
383454 req.Header.Set("Git-Protocol", v)
384455 }
385456
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
387467 if r.ContentLength < 0 || len(r.TransferEncoding) > 0 {
388468 req.ContentLength = -1
389469 req.TransferEncoding = nil
@@ -391,6 +471,34 @@ func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt rout
391471 return req, 0
392472 }
393473
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+
394502 // logWriter sends what the Git process (or the CGI layer) writes to its
395503 // standard error to the logger of the request.
396504 type logWriter struct {
M internal/adapters/githttp/handler_test.go +183 -86
@@ -14,6 +14,7 @@ import (
1414 "strings"
1515 "sync"
1616 "testing"
17+ "time"
1718
1819 "github.com/stretchr/testify/assert"
1920 "github.com/stretchr/testify/mock"
@@ -26,97 +27,152 @@ import (
2627 "bnema.dev/ermite/internal/ports/mocks"
2728 )
2829
29-type fakeAuth struct {
30- mu sync.Mutex
31- seen []string
30+// cgiScript is a stand-in for git-http-backend: it answers with the
31+// environment and the body it received. Like Git, it reads the whole request
32+// before it answers (net/http cannot read a request body once the response
33+// has started).
34+//
35+// The script is written once, in TestMain, before any test runs: writing an
36+// executable while parallel tests fork lets a child inherit the open write
37+// descriptor, and exec then fails with ETXTBSY (golang/go#22315).
38+func cgiScript(t *testing.T) string {
39+ t.Helper()
40+ require.NotEmpty(t, backendScript, "TestMain writes the backend script")
41+ return backendScript
3242 }
3343
34-func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) {
35- f.mu.Lock()
36- f.seen = append(f.seen, secret)
37- f.mu.Unlock()
38- switch secret {
39- case "root-secret":
40- return domain.Root(), nil
41- case "reader-secret":
42- return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
44+var backendScript string
45+
46+func TestMain(m *testing.M) {
47+ os.Exit(runWithBackendScript(m))
48+}
49+
50+func runWithBackendScript(m *testing.M) int {
51+ dir, err := os.MkdirTemp("", "githttp-backend")
52+ if err != nil {
53+ panic(err)
54+ }
55+ defer func() { _ = os.RemoveAll(dir) }()
56+ backendScript = filepath.Join(dir, "backend")
57+ body := "#!/bin/sh\nin=$(mktemp)\ncat > \"$in\"\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat \"$in\"\nrm -f \"$in\"\n"
58+ if err := os.WriteFile(backendScript, []byte(body), 0o755); err != nil {
59+ panic(err)
4360 }
44- return domain.Principal{}, domain.ErrUnauthenticated
61+ return m.Run()
4562 }
4663
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
4871
49-func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
50- switch n.String() {
51- case "pub":
52- return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
53- case "priv":
54- if p.Allows(domain.ScopeRepoRead, n) {
55- return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
56- }
57- }
58- return domain.Repo{}, domain.ErrNotFound
72+ pushFlags []bool
73+ backendErr error
74+
75+ mu sync.Mutex // guards what the mocks record while the server runs
76+ pushErr error // what Pushes.Open answers a named caller
77+ pushCalls []bool // the create flag of each Pushes.Open
78+ authCalls int // Authenticator.Authenticate calls
79+ released int // hook tokens released
80+ hookRepo domain.RepoName
5981 }
6082
61-type fakePushes struct {
62- mu sync.Mutex
63- calls []bool // the create flag of each Open
64- err error
83+func (r *rig) setPushErr(err error) {
84+ r.mu.Lock()
85+ defer r.mu.Unlock()
86+ r.pushErr = err
6587 }
6688
67-func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- f.calls = append(f.calls, create)
71- if p.IsAnonymous() {
72- return domain.ErrUnauthenticated
73- }
74- return f.err
89+func (r *rig) takePushCalls() []bool {
90+ r.mu.Lock()
91+ defer r.mu.Unlock()
92+ c := r.pushCalls
93+ r.pushCalls = nil
94+ return c
7595 }
7696
77-type fakeTokens struct {
78- mu sync.Mutex
79- released int
80- repo domain.RepoName
97+func (r *rig) authenticateCalls() int {
98+ r.mu.Lock()
99+ defer r.mu.Unlock()
100+ return r.authCalls
81101 }
82102
83-func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) {
84- f.mu.Lock()
85- defer f.mu.Unlock()
86- f.repo = r
87- return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() }
103+func (r *rig) releasedTokens() int {
104+ r.mu.Lock()
105+ defer r.mu.Unlock()
106+ return r.released
88107 }
89-func (f *fakeTokens) Socket() string { return "/run/hook.sock" }
90108
91-// cgiScript is a stand-in for git-http-backend: it answers with the
92-// environment and the body it received. Like Git, it reads the whole request
93-// before it answers (net/http cannot read a request body once the response
94-// has started).
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
109+func (r *rig) hookedRepo() domain.RepoName {
110+ r.mu.Lock()
111+ defer r.mu.Unlock()
112+ return r.hookRepo
101113 }
102114
103-type rig struct {
104- h *githttp.Handler
105- srv *httptest.Server
106- pushes *fakePushes
107- backend *mocks.GitBackend
108- hooks *fakeTokens
109- auth *fakeAuth
115+// stubUseCases answers like the use cases behind the handler: a root and a
116+// reader token, a public repository "pub" and a private one "priv".
117+func (r *rig) stubUseCases(repos *mocks.RepoLookup) {
118+ r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn(
119+ func(_ context.Context, secret string) (domain.Principal, error) {
120+ r.mu.Lock()
121+ r.authCalls++
122+ r.mu.Unlock()
123+ switch secret {
124+ case "root-secret":
125+ return domain.Root(), nil
126+ case "reader-secret":
127+ return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
128+ }
129+ return domain.Principal{}, domain.ErrUnauthenticated
130+ }).Maybe()
110131
111- pushFlags []bool
112- backendErr error
132+ repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
133+ func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
134+ switch n.String() {
135+ case "pub":
136+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
137+ case "priv":
138+ if p.Allows(domain.ScopeRepoRead, n) {
139+ return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
140+ }
141+ }
142+ return domain.Repo{}, domain.ErrNotFound
143+ }).Maybe()
144+
145+ r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
146+ func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
147+ r.mu.Lock()
148+ defer r.mu.Unlock()
149+ r.pushCalls = append(r.pushCalls, create)
150+ if p.IsAnonymous() {
151+ return domain.ErrUnauthenticated
152+ }
153+ return r.pushErr
154+ }).Maybe()
155+
156+ r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
157+ func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) {
158+ r.mu.Lock()
159+ defer r.mu.Unlock()
160+ r.hookRepo = n
161+ return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() }
162+ }).Maybe()
163+ r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe()
113164 }
114165
115166 func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
116167 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)
118174 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,
120176 }
121177 script := cgiScript(t)
122178 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
@@ -171,7 +227,7 @@ func envOf(body string) map[string]string {
171227 }
172228
173229 func TestHandlerRoutesAndStatuses(t *testing.T) {
174- r := newRig(t, nil)
230+ t.Parallel()
175231 tests := []struct {
176232 name string
177233 method string
@@ -200,6 +256,8 @@ func TestHandlerRoutesAndStatuses(t *testing.T) {
200256 }
201257 for _, tt := range tests {
202258 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
203261 user := ""
204262 if tt.pass != "" {
205263 user = "whatever" // the username is ignored
@@ -216,31 +274,31 @@ func TestHandlerRoutesAndStatuses(t *testing.T) {
216274 }
217275
218276 func TestHandlerPushAuthorizationAnswers(t *testing.T) {
277+ t.Parallel()
219278 r := newRig(t, nil)
220279 get := func() int {
221280 code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
222281 return code
223282 }
224283 assert.Equal(t, 200, get())
225- r.pushes.err = domain.ErrForbidden
284+ r.setPushErr(domain.ErrForbidden)
226285 assert.Equal(t, 403, get())
227- r.pushes.err = domain.ErrNotFound
286+ r.setPushErr(domain.ErrNotFound)
228287 assert.Equal(t, 404, get())
229- r.pushes.err = domain.ErrUnauthenticated
288+ r.setPushErr(domain.ErrUnauthenticated)
230289 assert.Equal(t, 401, get())
231- r.pushes.err = errBoom{}
290+ r.setPushErr(errBoom{})
232291 assert.Equal(t, 500, get())
233292
234293 // Only the first request of a push may create.
235- r.pushes.err = nil
236- r.pushes.calls = nil
294+ r.setPushErr(nil)
295+ r.takePushCalls()
237296 r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
238297 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())
240299 // Reads never reach the push use case.
241- r.pushes.calls = nil
242300 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())
244302 }
245303
246304 type errBoom struct{}
@@ -248,6 +306,7 @@ type errBoom struct{}
248306 func (errBoom) Error() string { return "boom with /internal/detail" }
249307
250308 func TestHandlerBackendEnvironment(t *testing.T) {
309+ t.Parallel()
251310 r := newRig(t, nil)
252311 hdr := map[string]string{
253312 "Cookie": "session=secret",
@@ -295,21 +354,23 @@ func TestHandlerBackendEnvironment(t *testing.T) {
295354 assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks")
296355 assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN")
297356 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")
299- assert.Equal(t, "pub", r.hooks.repo.String())
357+ assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends")
358+ assert.Equal(t, "pub", r.hookedRepo().String())
300359
301360 // The info/refs advertisement of a push does not register a hook token.
302361 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())
304363 }
305364
306365 func TestHandlerNormalizesRepositoryPath(t *testing.T) {
366+ t.Parallel()
307367 r := newRig(t, nil)
308368 _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
309369 assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git")
310370 }
311371
312372 func TestHandlerChunkedBodyIsStreamed(t *testing.T) {
373+ t.Parallel()
313374 r := newRig(t, nil)
314375 payload := strings.Repeat("x", 300_000)
315376 // An io.Reader of unknown length makes the client send chunked.
@@ -325,6 +386,7 @@ func TestHandlerChunkedBodyIsStreamed(t *testing.T) {
325386 }
326387
327388 func TestHandlerBodyLimit(t *testing.T) {
389+ t.Parallel()
328390 r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 })
329391 big := strings.Repeat("x", 5000)
330392 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) {
342404 }
343405
344406 func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) {
407+ t.Parallel()
345408 r := newRig(t, nil) // the push limit is 4 GiB
346409 big := strings.NewReader(strings.Repeat("x", 64<<20+1))
347410 code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", big, nil)
@@ -349,6 +412,7 @@ func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) {
349412 }
350413
351414 func TestHandlerServiceQuery(t *testing.T) {
415+ t.Parallel()
352416 r := newRig(t, nil)
353417 for _, q := range []string{
354418 "service=git-upload-pack&service=git-receive-pack",
@@ -366,6 +430,7 @@ func TestHandlerServiceQuery(t *testing.T) {
366430 }
367431
368432 func TestHandlerGitProtocolHeader(t *testing.T) {
433+ t.Parallel()
369434 r := newRig(t, nil)
370435 tests := []struct {
371436 value string
@@ -403,6 +468,7 @@ func TestHandlerDoesNotInheritTheProcessEnvironment(t *testing.T) {
403468 }
404469
405470 func TestHandlerBackendFailure(t *testing.T) {
471+ t.Parallel()
406472 r := newRig(t, nil)
407473 r.backendErr = io.ErrUnexpectedEOF
408474 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) {
411477 }
412478
413479 func TestHandlerLimiter(t *testing.T) {
480+ t.Parallel()
414481 r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) })
415482 for i := range clientlimit.MaxFailures {
416483 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) {
420487 assert.Equal(t, 429, code)
421488 assert.NotEmpty(t, hdr.Get("Retry-After"))
422489 // A blocked request does not even reach the authenticator.
423- r.auth.mu.Lock()
424- n := len(r.auth.seen)
425- r.auth.mu.Unlock()
490+ n := r.authenticateCalls()
426491 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
427- r.auth.mu.Lock()
428- assert.Equal(t, n, len(r.auth.seen))
429- r.auth.mu.Unlock()
492+ assert.Equal(t, n, r.authenticateCalls())
430493 }
431494
432495 func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
496+ t.Parallel()
433497 r := newRig(t, func(c *githttp.Config) {
434498 c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
435499 })
@@ -443,6 +507,7 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
443507 }
444508
445509 func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
510+ t.Parallel()
446511 r := newRig(t, nil)
447512 path := "/priv.git/info/refs?service=git-upload-pack"
448513 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"})
@@ -461,3 +526,35 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
461526 _ = resp.Body.Close()
462527 assert.Equal(t, 401, resp.StatusCode)
463528 }
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 (
1919 "github.com/bnema/zerowrap"
2020
2121 "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
2223 )
2324
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-
3325 // readTimeout bounds how long a connection may take to send its request line.
3426 const readTimeout = 30 * time.Second
3527
@@ -46,7 +38,7 @@ type hookEntry struct {
4638 // socket the hooks call back on. It implements HookTokens.
4739 type HookServer struct {
4840 path string
49- hooks PushHooks
41+ hooks ports.PushHookService
5042
5143 mu sync.RWMutex
5244 tokens map[string]hookEntry
@@ -57,10 +49,10 @@ type HookServer struct {
5749 wg sync.WaitGroup
5850 }
5951
60-var _ HookTokens = (*HookServer)(nil)
52+var _ ports.HookTokens = (*HookServer)(nil)
6153
6254 // 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 {
6456 return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}
6557 }
6658
@@ -183,6 +175,8 @@ func (s *HookServer) Close(ctx context.Context) {
183175 }
184176
185177 // 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'
186180 func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
187181 log := zerowrap.FromCtx(ctx)
188182 _ = conn.SetReadDeadline(time.Now().Add(readTimeout))
M internal/adapters/githttp/hooks_test.go +60 -92
@@ -18,10 +18,13 @@ import (
1818 "time"
1919
2020 "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/mock"
2122 "github.com/stretchr/testify/require"
2223
2324 "bnema.dev/ermite/internal/adapters/githttp"
2425 "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+ "bnema.dev/ermite/internal/ports/mocks"
2528 )
2629
2730 const (
@@ -29,46 +32,6 @@ const (
2932 oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
3033 )
3134
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-
7235 func repoName(t *testing.T, s string) domain.RepoName {
7336 t.Helper()
7437 n, err := domain.ParseRepoName(s)
@@ -77,7 +40,7 @@ func repoName(t *testing.T, s string) domain.RepoName {
7740 }
7841
7942 // 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) {
8144 t.Helper()
8245 dir, err := os.MkdirTemp("", "hk")
8346 require.NoError(t, err)
@@ -117,7 +80,8 @@ func raw(t *testing.T, sock, line string) githttp.HookReply {
11780 }
11881
11982 func TestHookSocketMode(t *testing.T) {
120- _, sock := startHooks(t, &fakeHooks{})
83+ t.Parallel()
84+ _, sock := startHooks(t, mocks.NewPushHookService(t))
12185 fi, err := os.Stat(sock)
12286 require.NoError(t, err)
12387 assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
@@ -125,26 +89,28 @@ func TestHookSocketMode(t *testing.T) {
12589 }
12690
12791 func TestHookServerReplacesStaleSocketFile(t *testing.T) {
92+ t.Parallel()
12893 dir, err := os.MkdirTemp("", "hk")
12994 require.NoError(t, err)
13095 defer func() { _ = os.RemoveAll(dir) }()
13196 sock := filepath.Join(dir, "hook.sock")
13297 require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))
133- s := githttp.NewHookServer(sock, &fakeHooks{})
98+ s := githttp.NewHookServer(sock, mocks.NewPushHookService(t))
13499 require.NoError(t, s.Listen(context.Background()))
135100 s.Close(context.Background())
136101 }
137102
138103 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))
140106 err := s.Listen(context.Background())
141107 require.Error(t, err)
142108 assert.Contains(t, err.Error(), "too long")
143109 }
144110
145111 func TestHookProtocol(t *testing.T) {
146- fh := &fakeHooks{}
147- srv, sock := startHooks(t, fh)
112+ hooks := mocks.NewPushHookService(t)
113+ srv, sock := startHooks(t, hooks)
148114 p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})
149115 tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p)
150116 tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root())
@@ -154,46 +120,42 @@ func TestHookProtocol(t *testing.T) {
154120 upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`
155121
156122 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()
157126 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
158127 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)
164128
129+ hooks.EXPECT().Record(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.IsRoot() }),
130+ repoName(t, "beta"), updates).Return(nil).Once()
165131 r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)
166132 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())
171133 })
172134
173135 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":[]}`)
175- assert.Equal(t, repoName(t, "alpha"), fh.last().repo)
136+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once()
137+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
138+ assert.True(t, r.OK)
176139 })
177140
178141 t.Run("refusal carries the message", func(t *testing.T) {
179- fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch"))
180- defer func() { fh.checkErr = nil }()
142+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
143+ Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once()
181144 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
182145 assert.False(t, r.OK)
183146 assert.Contains(t, r.Message, "rejected")
184147 })
185148
186149 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
187- fh.checkErr = errors.New("disk /secret/path exploded")
188- defer func() { fh.checkErr = nil }()
150+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
151+ Return(errors.New("disk /secret/path exploded")).Once()
189152 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
190153 assert.False(t, r.OK)
191154 assert.NotContains(t, r.Message, "/secret/path")
192155 })
193156
194157 t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {
195- fh.recErr = errors.New("boom")
196- defer func() { fh.recErr = nil }()
158+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("boom")).Once()
197159 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)
198160 assert.True(t, r.OK)
199161 assert.NotEmpty(t, r.Message)
@@ -205,18 +167,16 @@ func TestHookProtocol(t *testing.T) {
205167 assert.False(t, r.OK, tok)
206168 }
207169 releaseA()
208- before := len(fh.calls)
209170 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)
210171 assert.False(t, r.OK, "released token")
211172 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.
213174 releaseB()
214175 })
215176
216177 t.Run("malformed requests are refused", func(t *testing.T) {
217178 tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root())
218179 defer rel()
219- before := len(fh.calls)
220180 for _, line := range []string{
221181 `not json`,
222182 `{}`,
@@ -227,7 +187,7 @@ func TestHookProtocol(t *testing.T) {
227187 r := raw(t, sock, line)
228188 assert.False(t, r.OK, line)
229189 }
230- assert.Len(t, fh.calls, before)
190+ // The hooks mock expects no call: no use case ran.
231191 })
232192
233193 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) {
242202 // A shutdown while a push is being recorded must not drop the recording: the
243203 // push is already in the repository.
244204 func TestHookRecordSurvivesShutdown(t *testing.T) {
245- fh := &fakeHooks{}
205+ t.Parallel()
206+ hooks := mocks.NewPushHookService(t)
246207 started, proceed := make(chan struct{}), make(chan struct{})
247208 var cancelled, hasDeadline bool
248209 var ctxMu sync.Mutex
249- fh.onRecord = func(ctx context.Context) {
250- close(started)
251- <-proceed
252- ctxMu.Lock()
253- defer ctxMu.Unlock()
254- cancelled = ctx.Err() != nil
255- _, hasDeadline = ctx.Deadline()
256- }
210+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
211+ func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ []domain.RefUpdate) error {
212+ close(started)
213+ <-proceed
214+ ctxMu.Lock()
215+ defer ctxMu.Unlock()
216+ cancelled = ctx.Err() != nil
217+ _, hasDeadline = ctx.Deadline()
218+ return nil
219+ }).Once()
257220
258221 dir, err := os.MkdirTemp("", "hk")
259222 require.NoError(t, err)
260223 t.Cleanup(func() { _ = os.RemoveAll(dir) })
261224 sock := filepath.Join(dir, "hook.sock")
262- srv := githttp.NewHookServer(sock, fh)
225+ srv := githttp.NewHookServer(sock, hooks)
263226 require.NoError(t, srv.Listen(context.Background()))
264227 ctx, cancel := context.WithCancel(context.Background())
265228 done := make(chan error, 1)
@@ -294,9 +257,14 @@ func TestHookRecordSurvivesShutdown(t *testing.T) {
294257 }
295258
296259 func TestHookConcurrentTokens(t *testing.T) {
297- fh := &fakeHooks{}
298- srv, sock := startHooks(t, fh)
260+ t.Parallel()
261+ hooks := mocks.NewPushHookService(t)
262+ srv, sock := startHooks(t, hooks)
299263 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+ }
300268 for i := range 16 {
301269 wg.Add(1)
302270 go func() {
@@ -309,39 +277,35 @@ func TestHookConcurrentTokens(t *testing.T) {
309277 }()
310278 }
311279 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)
317280 }
318281
319282 func TestRunHook(t *testing.T) {
320283 ctx := context.Background()
321- fh := &fakeHooks{}
322- srv, sock := startHooks(t, fh)
284+ hooks := mocks.NewPushHookService(t)
285+ srv, sock := startHooks(t, hooks)
323286 tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())
324287 defer rel()
325288 input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"
326289
327290 t.Run("sends the lines git feeds the hook", func(t *testing.T) {
328291 var errOut bytes.Buffer
329- require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
330- assert.Equal(t, []domain.RefUpdate{
292+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{
331293 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
332294 {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))
334297 assert.Empty(t, errOut.String())
335298 })
336299
337300 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()
338303 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)
340304 })
341305
342306 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
343- fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope"))
344- defer func() { fh.checkErr = nil }()
307+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
308+ Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once()
345309 var errOut bytes.Buffer
346310 err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
347311 require.ErrorIs(t, err, githttp.ErrHookRejected)
@@ -400,6 +364,7 @@ func TestRunHook(t *testing.T) {
400364 }
401365
402366 func TestWriteHookScripts(t *testing.T) {
367+ t.Parallel()
403368 dir := filepath.Join(t.TempDir(), "run", "hooks")
404369 exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite"
405370 require.NoError(t, githttp.WriteHookScripts(dir, exe))
@@ -425,6 +390,9 @@ func TestWriteHookScripts(t *testing.T) {
425390 }
426391
427392 // 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.
428396 func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
429397 base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z")
430398 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 @@
33 package httpserver
44
55 import (
6+ "context"
67 "net/http"
8+ "sync"
9+ "time"
710
811 "github.com/bnema/zerowrap"
912 )
1013
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+
1122 // healthHandler answers 200 "ok" when every check passes and 503 otherwise.
1223 // 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.
1326 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+ )
1432 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+
1545 w.Header().Set("Cache-Control", "no-store")
1646 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
17- for _, check := range checks {
18- if err := check(r.Context()); err != nil {
19- log := zerowrap.FromCtx(r.Context())
20- log.Warn().Err(err).Msg("health check failed")
21- w.WriteHeader(http.StatusServiceUnavailable)
22- _, _ = w.Write([]byte("unavailable\n"))
23- return
24- }
47+ if !ok {
48+ w.WriteHeader(http.StatusServiceUnavailable)
49+ _, _ = w.Write([]byte("unavailable\n"))
50+ return
2551 }
2652 _, _ = w.Write([]byte("ok\n"))
2753 }
2854 }
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
44
55 import (
66 "net/http"
7+ "net/url"
78 "path"
89 "strings"
910 "time"
@@ -12,18 +13,33 @@ import (
1213 )
1314
1415 // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
15-// for htmx attribute expressions; inline scripts stay forbidden.
16-const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
16+// for htmx attribute expressions; inline scripts stay forbidden. Forms post
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+}
1729
1830 // withSecurityHeaders sets the security headers on every response, before
19-// the handler runs, so that errors and 404s carry them too.
20-func withSecurityHeaders(next http.Handler) http.Handler {
31+// the handler runs, so that errors and 404s carry them too. With hsts, the
32+// browser is told to stay on https.
33+func withSecurityHeaders(next http.Handler, hsts bool) http.Handler {
2134 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2235 h := w.Header()
2336 h.Set("Content-Security-Policy", contentSecurityPolicy)
2437 h.Set("X-Frame-Options", "DENY")
2538 h.Set("X-Content-Type-Options", "nosniff")
2639 h.Set("Referrer-Policy", "same-origin")
40+ if hsts {
41+ h.Set("Strict-Transport-Security", hstsValue)
42+ }
2743 next.ServeHTTP(w, r)
2844 })
2945 }
M internal/adapters/httpserver/server.go +7 -2
@@ -36,7 +36,12 @@ type Route struct {
3636 type Config struct {
3737 // Addr is the listen address, "host:port".
3838 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.
4045 HealthChecks []Check
4146 // Routes are added to the router next to /-/health.
4247 Routes []Route
@@ -71,7 +76,7 @@ func New(cfg Config) *Server {
7176 for _, r := range cfg.Routes {
7277 mux.Handle(r.Pattern, r.Handler)
7378 }
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}
7580 }
7681
7782 // Handler returns the root handler, for tests.
M internal/adapters/httpserver/server_test.go +76 -1
@@ -14,7 +14,9 @@ import (
1414 "os"
1515 "strings"
1616 "sync"
17+ "sync/atomic"
1718 "testing"
19+ "testing/synctest"
1820 "time"
1921
2022 "github.com/bnema/zerowrap"
@@ -25,7 +27,7 @@ import (
2527 )
2628
2729 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'",
2931 "X-Frame-Options": "DENY",
3032 "X-Content-Type-Options": "nosniff",
3133 "Referrer-Policy": "same-origin",
@@ -76,6 +78,79 @@ func TestHealth(t *testing.T) {
7678 }
7779 }
7880
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+
79154 func TestHeadOverTheWire(t *testing.T) {
80155 ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())
81156 defer ts.Close()
M internal/adapters/kvstore/store.go +14 -2
@@ -58,9 +58,21 @@ func (s *Store) Close(ctx context.Context) error {
5858
5959 // Check reports whether the database accepts writes. It returns the failure
6060 // 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.
6168 func (s *Store) Check(ctx context.Context) error {
62- var b kv.Batch // no ops: only the state of the database is checked
63- if _, err := s.db.Apply(ctx, &b); err != nil {
69+ if err := ctx.Err(); 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) {
6476 return fmt.Errorf("ermite-kv: %w", err)
6577 }
6678 return nil
M internal/adapters/kvstore/store_test.go +6 -0
@@ -144,6 +144,12 @@ func TestCheck(t *testing.T) {
144144 s, err := kvstore.Open(ctx, t.TempDir())
145145 require.NoError(t, err)
146146 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+
147153 require.NoError(t, s.Close(ctx))
148154 assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed)
149155 }
M internal/adapters/rpc/api.go +18 -22
@@ -2,9 +2,8 @@
22
33 // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
44 // ConnectRPC: handlers that map the messages of the contract to the use cases
5-// of app and back, the interceptor that authenticates callers, and the error
6-// mapping. The same handlers serve remote clients over HTTP and the web in
7-// process (see InProcessClient).
5+// of usecase and back, the interceptor that authenticates callers, and the error
6+// mapping.
87 package rpc
98
109 import (
@@ -16,9 +15,9 @@ import (
1615 "github.com/bnema/zerowrap"
1716
1817 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19- "bnema.dev/ermite/internal/app"
2018 "bnema.dev/ermite/internal/clientlimit"
2119 "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
2221 )
2322
2423 // MaxRequestBytes bounds the size of a request message. Responses are not
@@ -29,31 +28,28 @@ const MaxRequestBytes = 1 << 20
2928 // implements, as GetVersion lists them.
3029 var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
3130
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-
3831 // Config configures the API.
3932 type Config struct {
40- Auth Authenticator
41- Repos *app.Repos
42- Tokens *app.Tokens
43- Browse *app.Browse
44- Events *app.Events
33+ Auth ports.AuthService
34+ Repos ports.RepoService
35+ Tokens ports.TokenService
36+ Browse ports.BrowseService
37+ Events ports.EventService
4538 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
46- MergeRequests *app.MergeRequests
47- SigningKey *app.SigningKey
39+ MergeRequests ports.MergeRequestService
40+ SigningKey ports.SigningKeyService
4841 Version string
4942 // TrustedProxies are the networks allowed to set X-Forwarded-For.
5043 TrustedProxies []netip.Prefix
5144 // Limiter counts failed authentications; nil builds a default one. Share
5245 // it with the other adapters that authenticate, so that a client has one
53- // budget of failures.
54- Limiter *clientlimit.Limiter
46+ // budget of failures. Leave it unset rather than assigning a typed nil
47+ // pointer (a nil *T in the interface is not nil, and would be called).
48+ Limiter ports.FailureLimiter
5549 // Sessions are the owner's web sessions; nil serves no SessionService.
56- Sessions *app.Sessions
50+ // Leave it unset rather than assigning a typed nil pointer (a nil *T in
51+ // the interface is not nil, and the service would be served and called).
52+ Sessions ports.SessionService
5753 }
5854
5955 // API is the set of Connect handlers of the contract.
@@ -93,8 +89,8 @@ func New(cfg Config) *API {
9389 // it under /-/api/ and strips the prefix.
9490 func (a *API) Handler() http.Handler { return a.handler }
9591
96-// caller returns the principal the interceptor (or the in-process caller)
97-// put in ctx; anonymous when there is none.
92+// caller returns the principal the interceptor put in ctx; anonymous when
93+// there is none.
9894 func caller(ctx context.Context) domain.Principal {
9995 p, _ := domain.PrincipalFromContext(ctx)
10096 return p
M internal/adapters/rpc/convert.go +4 -4
@@ -9,8 +9,8 @@ import (
99 "google.golang.org/protobuf/types/known/timestamppb"
1010
1111 pb "bnema.dev/ermite-api/gen/ermite"
12- "bnema.dev/ermite/internal/app"
1312 "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
1414 )
1515
1616 // 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) {
5555 return "", fmt.Errorf("%w: %v", domain.ErrInvalidVisibility, v)
5656 }
5757
58-func repoToPB(v app.RepoView) *pb.Repo {
58+func repoToPB(v ports.RepoView) *pb.Repo {
5959 settings := v.Settings
6060 if settings.IsZero() {
6161 settings = domain.DefaultRepoSettings()
@@ -361,7 +361,7 @@ func refUpdatesToPB(updates []domain.RefUpdate) []*pb.RefUpdate {
361361
362362 // watchEventToPB maps one item of a watch; nil when the event has no
363363 // representation in the contract.
364-func watchEventToPB(we app.WatchEvent) *pb.Event {
364+func watchEventToPB(we ports.WatchEvent) *pb.Event {
365365 if we.Gap {
366366 return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}
367367 }
@@ -397,7 +397,7 @@ func watchEventToPB(we app.WatchEvent) *pb.Event {
397397 return out
398398 }
399399
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 {
401401 if we.Repo != nil {
402402 return repoToPB(*we.Repo)
403403 }
M internal/adapters/rpc/convert_test.go +21 -5
@@ -15,15 +15,17 @@ import (
1515 "google.golang.org/protobuf/types/known/fieldmaskpb"
1616
1717 pb "bnema.dev/ermite-api/gen/ermite"
18- "bnema.dev/ermite/internal/app"
1918 "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
2020 )
2121
2222 // Every value of the domain maps to a real value of the contract, never to
2323 // UNSPECIFIED, and the mappers that have an inverse round-trip. A value added
2424 // to the domain and forgotten here would reach clients as "not set".
2525 func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
26+ t.Parallel()
2627 t.Run("visibility", func(t *testing.T) {
28+ t.Parallel()
2729 for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
2830 got := visibilityToPB(v)
2931 assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
@@ -37,6 +39,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
3739 })
3840
3941 t.Run("scope", func(t *testing.T) {
42+ t.Parallel()
4043 for _, s := range domain.AllScopes() {
4144 got, ok := scopeToPB[s]
4245 require.True(t, ok, "scope %s has no counterpart in the contract", s)
@@ -58,6 +61,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
5861 })
5962
6063 t.Run("ref kind", func(t *testing.T) {
64+ t.Parallel()
6165 for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
6266 got := refKindToPB(k)
6367 assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
@@ -70,6 +74,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
7074 })
7175
7276 t.Run("entry kind", func(t *testing.T) {
77+ t.Parallel()
7378 seen := map[pb.EntryKind]domain.EntryKind{}
7479 for _, k := range []domain.EntryKind{
7580 domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
@@ -82,6 +87,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
8287 })
8388
8489 t.Run("file status", func(t *testing.T) {
90+ t.Parallel()
8591 seen := map[pb.FileStatus]domain.FileStatus{}
8692 for _, s := range []domain.FileStatus{
8793 domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
@@ -94,6 +100,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
94100 })
95101
96102 t.Run("line kind", func(t *testing.T) {
103+ t.Parallel()
97104 seen := map[pb.LineKind]domain.LineKind{}
98105 for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
99106 got := lineKindToPB(k)
@@ -106,6 +113,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
106113 // Every value of the contract except UNSPECIFIED is produced by some value
107114 // of the domain: nothing in the contract is unreachable.
108115 t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
116+ t.Parallel()
109117 produced := func(names map[int32]string, from func(v int32) bool) {
110118 for v, n := range names {
111119 if v == 0 {
@@ -142,6 +150,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
142150 }
143151
144152 func TestMergeModeMappers(t *testing.T) {
153+ t.Parallel()
145154 for _, m := range domain.MergeModes() {
146155 got := mergeModeToPB(m)
147156 assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
@@ -168,6 +177,7 @@ func TestMergeModeMappers(t *testing.T) {
168177 }
169178
170179 func TestMergeRequestEnumMappers(t *testing.T) {
180+ t.Parallel()
171181 for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
172182 got := mergeRequestStateToPB(s)
173183 assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
@@ -192,6 +202,7 @@ func TestMergeRequestEnumMappers(t *testing.T) {
192202 }
193203
194204 func TestCommentID(t *testing.T) {
205+ t.Parallel()
195206 assert.EqualValues(t, 7, commentID("7"))
196207 for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
197208 assert.Zero(t, commentID(bad), "%q names no comment", bad)
@@ -199,13 +210,14 @@ func TestCommentID(t *testing.T) {
199210 }
200211
201212 func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213+ t.Parallel()
202214 // 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")}})
204216 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
205217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
206218
207219 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}})
209221 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
210222 assert.False(t, r.GetSettings().GetFailingChecksBlock())
211223 }
@@ -218,6 +230,7 @@ func mustName(t *testing.T, s string) domain.RepoName {
218230 }
219231
220232 func TestUpdateParamsSettingsPaths(t *testing.T) {
233+ t.Parallel()
221234 msg := func(paths ...string) *pb.UpdateRepoRequest {
222235 return &pb.UpdateRepoRequest{
223236 UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
@@ -255,6 +268,7 @@ func TestUpdateParamsSettingsPaths(t *testing.T) {
255268 }
256269
257270 func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
271+ t.Parallel()
258272 var v domain.Violations
259273 v.Add("title", "must be one line")
260274 v.Add("source_branch", "does not exist")
@@ -282,6 +296,7 @@ func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
282296 }
283297
284298 func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
299+ t.Parallel()
285300 repo := mustName(t, "proj")
286301 title, err := domain.ParseMRTitle("Add x")
287302 require.NoError(t, err)
@@ -297,7 +312,8 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
297312 "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
298313 } {
299314 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})
301317 require.NotNil(t, got)
302318 assert.Equal(t, "e1", got.GetId())
303319 assert.Equal(t, at, got.GetTime().AsTime())
@@ -311,7 +327,7 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
311327 }
312328
313329 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}})
315331 added := got.GetCommentAdded()
316332 require.NotNil(t, added)
317333 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) {
5656 case errors.Is(err, domain.ErrResourceExhausted):
5757 return connect.CodeResourceExhausted, true
5858 }
59- for _, invalid := range invalidErrors {
60- if errors.Is(err, invalid) {
61- return connect.CodeInvalidArgument, true
62- }
59+ if domain.IsInvalid(err) {
60+ return connect.CodeInvalidArgument, true
6361 }
6462 return 0, false
6563 }
6664
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-
7665 // addFieldDetail attaches the fields a request was rejected for as a
7766 // BadRequest detail, when err carries them.
7867 func addFieldDetail(ce *connect.Error, err error) {
M internal/adapters/rpc/event.go +4 -4
@@ -8,11 +8,11 @@ import (
88 "connectrpc.com/connect"
99
1010 pb "bnema.dev/ermite-api/gen/ermite"
11- "bnema.dev/ermite/internal/app"
11+ "bnema.dev/ermite/internal/ports"
1212 )
1313
1414 // eventService implements ermiteconnect.EventServiceHandler.
15-type eventService struct{ events *app.Events }
15+type eventService struct{ events ports.EventService }
1616
1717 // Watch streams events until the client goes away or the server stops. The
1818 // stream ends with success in both cases: the contract has no error for
@@ -20,7 +20,7 @@ type eventService struct{ events *app.Events }
2020 // the caller is revoked or expires, and a caller that has too many streams
2121 // open gets resource_exhausted.
2222 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{
2424 Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(),
2525 Client: clientFromContext(ctx),
2626 // 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
2828 // them for a nil message. A failure shows on the first real send.
2929 Ready: func() { _ = stream.Send(nil) },
3030 }
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 {
3232 ev := watchEventToPB(we)
3333 if ev == nil {
3434 return nil
M internal/adapters/rpc/git.go +4 -4
@@ -11,15 +11,15 @@ import (
1111 "connectrpc.com/connect"
1212
1313 pb "bnema.dev/ermite-api/gen/ermite"
14- "bnema.dev/ermite/internal/app"
1514 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
1616 )
1717
1818 // BlobChunkSize is the largest chunk of a GetBlob stream.
1919 const BlobChunkSize = 64 << 10
2020
2121 // gitService implements ermiteconnect.GitServiceHandler.
22-type gitService struct{ browse *app.Browse }
22+type gitService struct{ browse ports.BrowseService }
2323
2424 // target is the repository and revision most requests start with.
2525 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
3939 if err != nil {
4040 return nil, connectError(ctx, err)
4141 }
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()}
4343 for _, k := range req.Msg.GetKinds() {
4444 kind, err := refKindFromPB(k)
4545 if err != nil {
@@ -162,7 +162,7 @@ func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.Li
162162 if err != nil {
163163 return nil, connectError(ctx, err)
164164 }
165- page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{
165+ page, err := s.browse.ListCommits(ctx, caller(ctx), name, ports.ListCommitsParams{
166166 Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
167167 })
168168 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 (
1818 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1919 "bnema.dev/ermite/internal/clientlimit"
2020 "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
2122 )
2223
2324 var errInternal = errors.New("internal error")
@@ -30,15 +31,12 @@ var errInternal = errors.New("internal error")
3031 // limiter; a client that used up its failures gets resource_exhausted (429 on
3132 // the Connect protocol) whatever it presents, like on the Git routes.
3233 //
33-// A call made through InProcessClient is trusted with the principal its
34-// context carries: that is how the web calls the handlers in process. What
35-// marks it is a value only the in-process transport can put in the context
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.
34+// A principal in the context of a network request, say one put there by a
35+// server's base context, is ignored and the request is authenticated from its
36+// headers.
3937 type interceptor struct {
40- auth Authenticator
41- limiter *clientlimit.Limiter
38+ auth ports.AuthService
39+ limiter ports.FailureLimiter
4240 trusted []netip.Prefix
4341 }
4442
@@ -48,11 +46,6 @@ func newInterceptor(cfg Config) *interceptor {
4846
4947 // authenticate returns the context to run the call in.
5048 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- }
5649 // Whatever the context carried, the caller is anonymous until proven
5750 // otherwise.
5851 ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
@@ -90,18 +83,8 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
9083
9184 type clientKey struct{}
9285
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-
10286 // clientFromContext returns the normalized address of the client of a call:
103-// its peer for a network call, the one given to ContextWithClient for an
104-// in-process call, empty when there is none.
87+// its peer, empty when there is none.
10588 func clientFromContext(ctx context.Context) string {
10689 c, _ := ctx.Value(clientKey{}).(string)
10790 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 (
1010 "connectrpc.com/connect"
1111
1212 pb "bnema.dev/ermite-api/gen/ermite"
13- "bnema.dev/ermite/internal/app"
1413 "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
1515 )
1616
1717 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18-type mergeRequestService struct{ mrs *app.MergeRequests }
18+type mergeRequestService struct{ mrs ports.MergeRequestService }
1919
2020 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
2121 switch s {
@@ -129,7 +129,7 @@ func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *conne
129129 if err != nil {
130130 return nil, connectError(ctx, err)
131131 }
132- mr, err := s.mrs.Create(ctx, caller(ctx), name, app.CreateMergeRequestParams{
132+ mr, err := s.mrs.Create(ctx, caller(ctx), name, ports.CreateMergeRequestParams{
133133 Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(),
134134 Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(),
135135 })
@@ -148,7 +148,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec
148148 if err != nil {
149149 return nil, connectError(ctx, err)
150150 }
151- page, err := s.mrs.List(ctx, caller(ctx), name, app.ListMergeRequestsParams{
151+ page, err := s.mrs.List(ctx, caller(ctx), name, ports.ListMergeRequestsParams{
152152 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
153153 })
154154 if err != nil {
@@ -197,7 +197,7 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
197197 if err != nil {
198198 return nil, connectError(ctx, err)
199199 }
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{
201201 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
202202 })
203203 if err != nil {
@@ -245,7 +245,7 @@ func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connec
245245 if err != nil {
246246 return nil, connectError(ctx, err)
247247 }
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{
249249 Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),
250250 })
251251 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 (
99 "connectrpc.com/connect"
1010
1111 pb "bnema.dev/ermite-api/gen/ermite"
12- "bnema.dev/ermite/internal/app"
1312 "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
1414 )
1515
1616 // repoService implements ermiteconnect.RepoServiceHandler.
17-type repoService struct{ repos *app.Repos }
17+type repoService struct{ repos ports.RepoService }
1818
1919 func (s *repoService) ListRepos(ctx context.Context, req *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) {
2020 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
5353 if err != nil {
5454 return nil, connectError(ctx, err)
5555 }
56- repo, err := s.repos.Create(ctx, caller(ctx), app.CreateParams{
56+ repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{
5757 Name: name, Description: req.Msg.GetDescription(), Visibility: vis,
5858 })
5959 if err != nil {
@@ -84,8 +84,8 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8484 // nothing else.
8585 //
8686 //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) {
88- var params app.UpdateParams
87+func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
88+ var params ports.UpdateRepoParams
8989 paths := msg.GetUpdateMask().GetPaths()
9090 if len(paths) == 0 {
9191 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
135135 }
136136
137137 // 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) {
139139 view, err := repos.View(ctx, repo)
140140 if err != nil {
141141 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 (
99 "connectrpc.com/connect"
1010
1111 pb "bnema.dev/ermite-api/gen/ermite"
12- "bnema.dev/ermite/internal/app"
12+ "bnema.dev/ermite/internal/ports"
1313 )
1414
1515 // serverService implements ermiteconnect.ServerServiceHandler.
1616 type serverService struct {
1717 version string
18- key *app.SigningKey
18+ key ports.SigningKeyService
1919 }
2020
2121 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 (
88 "connectrpc.com/connect"
99
1010 pb "bnema.dev/ermite-api/gen/ermite"
11- "bnema.dev/ermite/internal/app"
11+ "bnema.dev/ermite/internal/ports"
1212 )
1313
1414 // sessionService implements ermiteconnect.SessionServiceHandler. The browser
1515 // 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 }
1717
1818 func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) {
1919 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 (
88 "connectrpc.com/connect"
99
1010 pb "bnema.dev/ermite-api/gen/ermite"
11- "bnema.dev/ermite/internal/app"
1211 "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
1313 )
1414
1515 // tokenService implements ermiteconnect.TokenServiceHandler.
16-type tokenService struct{ tokens *app.Tokens }
16+type tokenService struct{ tokens ports.TokenService }
1717
1818 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{
2020 Label: req.Msg.GetLabel(),
2121 ExpiresAt: goTime(req.Msg.GetExpiresAt()),
2222 }
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.
88 | `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` |
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
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 | |
1212 | `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 | |
1313 | `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 | |
1414 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1515 | `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` |
1616 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
1717 | `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 | |
1919
2020 The three htmx files are the exact files the approved mockup was built with.
2121 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`).
2323
2424 `*_templ.go` files are generated by `go tool templ generate` (templ is pinned
2525 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 {
3434 type asset struct {
3535 body []byte
3636 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
3740 }
3841
3942 // assetSet is the embedded files by hashed URL path (under StaticPrefix).
@@ -71,7 +74,13 @@ func loadAssets() (*assetSet, error) {
7174 sum := sha256.Sum256(body)
7275 ext := path.Ext(file)
7376 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
7584 return StaticPrefix + name
7685 }
7786
@@ -149,8 +158,25 @@ func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
149158 h := w.Header()
150159 h.Set("Content-Type", a.typ)
151160 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)))
153170 if r.Method != http.MethodHead {
154- _, _ = w.Write(a.body)
171+ _, _ = w.Write(body)
155172 }
156173 }
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
44
55 import (
66 "context"
7+ "errors"
78 "fmt"
89 "html/template"
10+ "io"
911 "net/http"
1012 "path"
1113 "strings"
1214
13- "connectrpc.com/connect"
1415 "github.com/a-h/templ"
1516 "github.com/bnema/zerowrap"
1617
17- pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
1819 )
1920
2021 const (
@@ -45,43 +46,43 @@ type BlobPage struct {
4546 DirHref string
4647 }
4748
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.
4950 type blobStream struct {
50- stream *connect.ServerStreamForClient[pb.GetBlobResponse]
51- info *pb.BlobInfo
52- // buf holds the content already received and not yet handed out.
51+ body io.ReadCloser
52+ info domain.BlobInfo
53+ // buf holds the content already read and not yet handed out.
5354 buf []byte
55+ // eof is true once body has nothing more.
56+ eof bool
5457 }
5558
56-// openBlob starts the stream of a file and reads its metadata.
57-func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) {
58- if 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}))
59+// openBlob opens a file of the repository.
60+func (s *Server) openBlob(ctx context.Context, repo domain.RepoName, t target) (*blobStream, error) {
61+ info, body, err := s.openBlobAt(ctx, repo, t.rev, t.path)
6262 if err != nil {
6363 return nil, err
6464 }
65- if !st.Receive() {
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
65+ return &blobStream{body: body, info: info}, nil
7566 }
7667
77-func (b *blobStream) close() { _ = b.stream.Close() }
68+func (b *blobStream) close() { _ = b.body.Close() }
7869
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.
8071 func (b *blobStream) fill(n int) error {
81- for len(b.buf) < n && b.stream.Receive() {
82- b.buf = append(b.buf, b.stream.Msg().GetChunk()...)
72+ if b.eof || len(b.buf) >= n {
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
8384 }
84- return b.stream.Err()
85+ return err
8586 }
8687
8788 // 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
110111 if !ok {
111112 return nil, 0, errNotFound
112113 }
113- b, err := s.openBlob(ctx, d.name(), t)
114+ b, err := s.openBlob(ctx, d.repo.Name, t)
114115 if err != nil {
115116 return nil, 0, err
116117 }
@@ -121,7 +122,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
121122 if dir == "." {
122123 dir = ""
123124 }
124- size := b.info.GetSize()
125+ size := b.info.Size
125126 p := BlobPage{
126127 Name: visibleText(name),
127128 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
129130 DirHref: repoHref(d.name(), "tree", t.name, dir),
130131 Facts: []Fact{{Key: "size", Val: humanSize(size)}},
131132 }
132- typ := b.info.GetMediaType()
133+ typ := b.info.MediaType
133134 lang := ""
134135
135136 // 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
142143 switch {
143144 case img != "":
144145 p.Image, typ = true, img
145- case b.info.GetBinary():
146+ case b.info.Binary:
146147 p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size))
147148 case size > maxCodeBytes:
148149 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
180181 }
181182 p.Facts = append(p.Facts, Fact{Key: "type", Val: lang})
182183
183- if last := s.lastChange(ctx, d.name(), t); last != nil {
184+ if last := s.lastChange(ctx, d.repo.Name, t); last != nil {
184185 p.Last = last
185186 }
186187
187188 l := s.repoLayout(ctx, d, "tree")
188189 // A blob stream does not name its commit: an abbreviated id is resolved
189190 // for the finder.
190- s.resolveCommit(ctx, d.name(), &t)
191+ s.resolveCommit(ctx, d.repo.Name, &t)
191192 pinRef(&l, t)
192193 l.Crumbs = pathCrumbs(d.name(), "blob", t)
193194 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
197198
198199 // lastChange is the latest commit that touched the path, nil when it cannot
199200 // be found (the page does not depend on it).
200-func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow {
201- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
202- Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1,
203- }))
204- if err != nil || len(res.Msg.GetCommits()) != 1 {
201+func (s *Server) lastChange(ctx context.Context, repo domain.RepoName, t target) *CommitRow {
202+ res, err := s.commitList(ctx, repo, t.rev, t.path, 1, "")
203+ if err != nil || len(res.Commits) != 1 {
205204 return nil
206205 }
207- row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now())
206+ row := commitRow(repo.String(), res.Commits[0], s.cfg.Now())
208207 return &row
209208 }
210209
M internal/adapters/web/blob.templ +1 -1
@@ -54,7 +54,7 @@ templ CodeView(h template.HTML) {
5454 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })"
5555 hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }"
5656 data-ready=""
57- ><code>@templ.Raw(string(h))</code></pre>
57+ ><code hx-ignore>@templ.Raw(string(h))</code></pre>
5858 }
5959
6060 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 (
99 "strings"
1010 "time"
1111
12- "connectrpc.com/connect"
1312 "github.com/a-h/templ"
1413
15- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
1615 )
1716
1817 // 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) {
9190
9291 // commit shows a commit: its message, its totals, the changed files and the
9392 // 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
9495 func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
9596 sha := r.PathValue("sha")
9697 if !isCommitID(sha) {
@@ -100,57 +101,55 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
100101 if err != nil {
101102 return nil, 0, err
102103 }
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)
104105 if err != nil {
105106 return nil, 0, err
106107 }
107- c := res.Msg.GetCommit()
108- if c == nil {
109- return nil, 0, errNotFound
110- }
111- stats := res.Msg.GetStats()
108+ c := res.Commit
109+ cid := c.ID.String()
112110 now := s.cfg.Now()
113111
114112 p := CommitPage{
115- SHA: c.GetId(), Short: short(c.GetId()),
116- Subject: visibleText(c.GetSummary()), Body: commitBody(c),
117- Author: person(c.GetAuthor(), now),
118- Merge: len(c.GetParentIds()) > 1,
119- Root: len(c.GetParentIds()) == 0,
120- NFiles: int(stats.GetFilesChanged()),
121- Adds: int(stats.GetAdditions()),
122- Dels: int(stats.GetDeletions()),
123- Files: diffFiles(res.Msg.GetDiff().GetFiles()),
124- }
125- if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() {
113+ SHA: cid, Short: short(cid),
114+ Subject: visibleText(c.Summary), Body: commitBody(c),
115+ Author: person(c.Author, now),
116+ Merge: len(c.Parents) > 1,
117+ Root: len(c.Parents) == 0,
118+ NFiles: res.Stats.FilesChanged,
119+ Adds: res.Stats.Additions,
120+ Dels: res.Stats.Deletions,
121+ Files: diffFiles(res.Diff.Files),
122+ }
123+ if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
126124 p.Committer = &cm
127125 }
128- for _, id := range c.GetParentIds() {
126+ for _, pid := range c.Parents {
127+ id := pid.String()
129128 p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
130129 }
131130 for _, b := range d.branches {
132- if b.GetCommitId() == c.GetId() {
133- p.Refs = append(p.Refs, b.GetShortName())
131+ if b.CommitID.String() == cid {
132+ p.Refs = append(p.Refs, b.ShortName)
134133 }
135134 }
136135 for _, t := range d.tags {
137- if t.GetCommitId() == c.GetId() {
138- p.Refs = append(p.Refs, "tag: "+t.GetShortName())
136+ if t.CommitID.String() == cid {
137+ p.Refs = append(p.Refs, "tag: "+t.ShortName)
139138 }
140139 }
141- p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
140+ p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
142141
143142 lines, invisible := diffLines(p.Files)
144143 p.Invisible = invisible
145- base := repoHref(d.name(), "commit", c.GetId())
144+ base := repoHref(d.name(), "commit", cid)
146145 p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split")
147146
148147 l := s.repoLayout(ctx, d, "commits")
149148 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})
151150 history := repoHref(d.name())
152151 if d.head != nil {
153- history = repoHref(d.name(), "commits", d.head.GetShortName())
152+ history = repoHref(d.name(), "commits", d.head.ShortName)
154153 }
155154 l.Crumbs = []Crumb{
156155 {Label: d.name(), Href: repoHref(d.name())},
@@ -162,25 +161,25 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
162161 }
163162
164163 // person converts a signature.
165-func person(s *pb.Signature, now time.Time) Person {
166- p := Person{Name: visibleText(s.GetName())}
167- p.When, p.WhenAt = whenOf(now, s.GetTime())
168- if t := s.GetTime(); t.IsValid() {
169- p.Abs = absolute(t.AsTime())
164+func person(s domain.Signature, now time.Time) Person {
165+ p := Person{Name: visibleText(s.Name)}
166+ p.When, p.WhenAt = whenOf(now, s.Time)
167+ if !s.Time.IsZero() {
168+ p.Abs = absolute(s.Time)
170169 }
171170 return p
172171 }
173172
174173 // commitBody is the message without its first line, for display.
175-func commitBody(c *pb.Commit) string {
176- msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n")
174+func commitBody(c domain.Commit) string {
175+ msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
177176 _, body, _ := strings.Cut(msg, "\n")
178177 return messageText(strings.TrimSpace(body))
179178 }
180179
181180 // diffNote says what is missing from an incomplete diff, "" for a complete one.
182-func diffNote(d *pb.Diff, shown, total int) string {
183- if !d.GetTruncated() {
181+func diffNote(d domain.Diff, shown, total int) string {
182+ if !d.Truncated {
184183 return ""
185184 }
186185 var parts []string
@@ -188,8 +187,8 @@ func diffNote(d *pb.Diff, shown, total int) string {
188187 parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
189188 }
190189 cut := 0
191- for _, f := range d.GetFiles() {
192- if f.GetTruncated() {
190+ for _, f := range d.Files {
191+ if f.Truncated {
193192 cut++
194193 }
195194 }
M internal/adapters/web/commits.go +13 -16
@@ -8,10 +8,9 @@ import (
88 "net/url"
99 "path"
1010
11- "connectrpc.com/connect"
1211 "github.com/a-h/templ"
1312
14- pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
1514 )
1615
1716 // commitsPerPage is how many commits one page of history lists.
@@ -44,7 +43,7 @@ type CommitsPage struct {
4443 }
4544
4645 // commits lists the history of a ref, newest first, a page at a time, with
47-// an optional path to limit it to. The page token of the API travels in the
46+// an optional path to limit it to. The page token of the use case travels in the
4847 // "page" query parameter.
4948 func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) {
5049 d, err := s.loadRepo(ctx, r.PathValue("repo"))
@@ -56,13 +55,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
5655 return nil, 0, errNotFound
5756 }
5857 token := r.URL.Query().Get("page")
59- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
60- Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token,
61- }))
58+ res, err := s.commitList(ctx, d.repo.Name, sel.rev, sel.path, commitsPerPage, token)
6259 if err != nil {
6360 return nil, 0, err
6461 }
65- cs := res.Msg.GetCommits()
62+ cs := res.Commits
6663 if len(cs) == 0 && token == "" && sel.path != "" {
6764 // Git answers an empty history for a path that never existed.
6865 return nil, 0, errNotFound
@@ -70,7 +67,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
7067
7168 p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)}
7269 base := repoHref(d.name(), "commits", sel.name, sel.path)
73- if t := res.Msg.GetNextPageToken(); t != "" {
70+ if t := res.NextPageToken; t != "" {
7471 p.Older = base + "?page=" + url.QueryEscape(t)
7572 }
7673 if token != "" {
@@ -99,16 +96,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
9996 }
10097
10198 // 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) {
103100 now := s.cfg.Now()
104101 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
105102 for i, c := range cs {
106103 row := commitRow(d.name(), c, now)
107104 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()]
109106 day := "unknown date"
110- if t := c.GetCommitter().GetTime(); t.IsValid() {
111- day = t.AsTime().UTC().Format("2006-01-02")
107+ if t := c.Committer.Time; !t.IsZero() {
108+ day = t.UTC().Format("2006-01-02")
112109 }
113110 if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
114111 p.Days = append(p.Days, DayGroup{Day: day})
@@ -132,15 +129,15 @@ func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link {
132129 if dir == "." {
133130 dir = ""
134131 }
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)
136133 if err != nil {
137134 return nil
138135 }
139- for _, e := range tree.Msg.GetEntries() {
140- if e.GetPath() != sel.path {
136+ for _, e := range tree.Entries {
137+ if e.Path != sel.path {
141138 continue
142139 }
143- if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY {
140+ if e.Kind == domain.EntryDirectory {
144141 return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)}
145142 }
146143 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) {
1111 <input
1212 id="commit-filter"
1313 type="search"
14- data-filter
14+ data-filter=".days tr"
15+ data-groups=".days section"
1516 placeholder="Filter by subject, sha or author…"
1617 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- 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')"
18+ hx-on:input="ermite.filter(this)"
2019 />
2120 </div>
2221 if p.Path != "" {
2322 <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p>
2423 }
2524 if p.Live {
26- <div id="commits-head"></div>
25+ @LiveSlot("commits-head")
2726 }
2827 <div class="days">
2928 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
44
55 import (
66 "errors"
7- "net/http"
87 "testing"
98
109 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212
13- pb "bnema.dev/ermite-api/gen/ermite"
14- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
1516 )
1617
1718 func TestIsCommitID(t *testing.T) {
19+ t.Parallel()
1820 for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} {
1921 assert.True(t, isCommitID(s), s)
2022 }
@@ -26,15 +28,22 @@ func TestIsCommitID(t *testing.T) {
2628 // The ref switcher marks the ref the page shows, whatever the page: a tag, a
2729 // branch other than the default one, or none for a commit.
2830 func TestPinRefMarksCurrent(t *testing.T) {
29- b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
31+ t.Parallel()
32+ b, tag := domain.RefKindBranch, domain.RefKindTag
33+ name, err := domain.ParseRepoName("demo")
34+ require.NoError(t, err)
3035 d := &repoData{
31- repo: &pb.Repo{Name: "demo", DefaultBranch: "main"},
32- branches: []*pb.Ref{ref(b, "main"), ref(b, "side")},
33- tags: []*pb.Ref{ref(tag, "v1")},
36+ repo: ports.RepoView{Repo: domain.Repo{Name: name}, DefaultBranch: "main"},
37+ branches: []domain.Ref{ref(b, "main"), ref(b, "side")},
38+ tags: []domain.Ref{ref(tag, "v1")},
3439 }
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"))
3645 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),
3847 PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
3948 })
4049 require.NoError(t, err)
@@ -51,9 +60,9 @@ func TestPinRefMarksCurrent(t *testing.T) {
5160 heads, tags []string
5261 ref, short string
5362 }{
54- {"", []string{"main"}, nil, "main", "c-main"},
55- {"v1", nil, []string{"v1"}, "v1", "c-v1"},
56- {"side", []string{"side"}, nil, "side", "c-side"},
63+ {"", []string{"main"}, nil, "main", short(commitOf("main").String())},
64+ {"v1", nil, []string{"v1"}, "v1", short(commitOf("v1").String())},
65+ {"side", []string{"side"}, nil, "side", short(commitOf("side").String())},
5766 {"abcdef0123", nil, nil, "detached", "abcdef0"},
5867 } {
5968 tg, ok := d.locate(tt.rest)
M internal/adapters/web/compare.go +43 -43
@@ -7,10 +7,10 @@ import (
77 "net/http"
88 "slices"
99
10- "connectrpc.com/connect"
1110 "github.com/a-h/templ"
1211
13- pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
1414 )
1515
1616 // compareDepth bounds the commits read on each side to find where a branch
@@ -27,6 +27,8 @@ const compareDepth = maxComparePages * pageSize
2727 // own diff is then the whole change). Otherwise it lists the commits, which
2828 // link to their own diffs, and says the whole diff comes with the merge
2929 // 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
3032 func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
3133 d, err := s.loadRepo(ctx, r.PathValue("repo"))
3234 if err != nil {
@@ -36,25 +38,25 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
3638 if !ok || rest != "" || d.head == nil {
3739 return nil, 0, errNotFound
3840 }
39- tgt := d.head
41+ tgt := *d.head
4042 name := d.name()
4143
4244 p := ComparePage{
43- Source: visibleText(src.GetShortName()), Target: visibleText(tgt.GetShortName()),
44- SourceHref: repoHref(name, "tree", src.GetShortName()), TargetHref: repoHref(name, "tree", tgt.GetShortName()),
45+ Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName),
46+ SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName),
4547 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>",
4749 }},
4850 }
4951
50- same := src.GetShortName() == tgt.GetShortName()
51- var ahead []*pb.Commit
52+ same := src.ShortName == tgt.ShortName
53+ var ahead []domain.Commit
5254 exact, behind := false, false
5355 if same {
5456 p.Note = "this is the default branch: there is nothing to compare it with."
5557 p.AheadText = "0 commits"
5658 } 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())
5860 if err != nil {
5961 return nil, 0, err
6062 }
@@ -79,19 +81,19 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
7981
8082 // The exact diff: one commit on top of the tip of the target.
8183 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())
8385 if err != nil {
8486 return nil, 0, err
8587 }
8688 p.HasDiff = true
87- p.Files = diffFiles(res.Msg.GetDiff().GetFiles())
88- p.NFiles, p.Adds, p.Dels = int(res.Msg.GetStats().GetFilesChanged()), int(res.Msg.GetStats().GetAdditions()), int(res.Msg.GetStats().GetDeletions())
89- if n := diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles); n != "" {
89+ p.Files = diffFiles(res.Diff.Files)
90+ p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
91+ if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
9092 p.Note = n
9193 }
9294 lines, invisible := diffLines(p.Files)
9395 p.Invisible = invisible
94- self := repoHref(name, "compare", src.GetShortName())
96+ self := repoHref(name, "compare", src.ShortName)
9597 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
9698 } else if len(ahead) > 1 && exact {
9799 if p.Note == "" {
@@ -100,32 +102,32 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
100102 }
101103
102104 // An open request for the same pair, or the form to open one.
103- var existing *pb.MergeRequest
105+ var existing *domain.MergeRequest
104106 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)
106108 if err != nil {
107109 return nil, 0, err
108110 }
109111 }
110112 switch {
111113 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)}
113115 case SignedIn(ctx) && !same && len(ahead) > 0:
114116 title := ""
115117 if len(ahead) == 1 {
116- title = ahead[0].GetSummary()
118+ title = ahead[0].Summary
117119 } else {
118- title = ahead[len(ahead)-1].GetSummary()
120+ title = ahead[len(ahead)-1].Summary
119121 }
120122 p.Create = &CreateForm{
121- Action: mrBase(name), Source: src.GetShortName(), Target: tgt.GetShortName(),
123+ Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName,
122124 Title: cutRunes(title, 120),
123125 }
124126 }
125127
126128 l := s.repoLayout(ctx, d, "merge-requests")
127129 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})
129131 l.Crumbs[0].Current = false
130132 l.Crumbs = append(l.Crumbs,
131133 Crumb{Label: "merge requests", Href: mrBase(name)},
@@ -140,67 +142,65 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
140142 // first. exact is false when the walk gave up before finding where the source
141143 // meets the target. behind is true when the target has commits the source
142144 // 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) {
144148 if srcTip == tgtTip {
145149 return nil, true, false, nil
146150 }
147151 onTarget := map[string]bool{}
148152 tokenT := ""
149153 for len(onTarget) < compareDepth {
150- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
151- Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT,
152- }))
154+ res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT)
153155 if err != nil {
154156 return nil, false, false, err
155157 }
156- for _, c := range res.Msg.GetCommits() {
157- onTarget[c.GetId()] = true
158+ for _, c := range res.Commits {
159+ onTarget[c.ID.String()] = true
158160 }
159- if tokenT = res.Msg.GetNextPageToken(); tokenT == "" {
161+ if tokenT = res.NextPageToken; tokenT == "" {
160162 break
161163 }
162164 }
163165 token := ""
164166 for n := 0; n < compareDepth; {
165- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
166- Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token,
167- }))
167+ res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token)
168168 if err != nil {
169169 return nil, false, false, err
170170 }
171- for _, c := range res.Msg.GetCommits() {
171+ for _, c := range res.Commits {
172172 n++
173- if onTarget[c.GetId()] {
173+ if onTarget[c.ID.String()] {
174174 // The first commit of the source that the target has: where the
175175 // branch left it. The target moved on when this is not its tip.
176- return ahead, true, c.GetId() != tgtTip, nil
176+ return ahead, true, c.ID.String() != tgtTip, nil
177177 }
178178 ahead = append(ahead, c)
179179 }
180- if token = res.Msg.GetNextPageToken(); token == "" {
180+ if token = res.NextPageToken; token == "" {
181181 // The whole history of the source is ahead: no common history.
182182 return ahead, true, true, nil
183183 }
184184 }
185- return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil
185+ return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil
186186 }
187187
188188 // 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) {
190190 token := ""
191191 for range maxOpenPages {
192- res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
193- Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token,
194- }))
192+ res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{
193+ State: domain.MRStateOpen, PageSize: pageSize, PageToken: token,
194+ })
195195 if err != nil {
196196 return nil, err
197197 }
198- for _, m := range res.Msg.GetMergeRequests() {
199- if m.GetSourceBranch() == source && m.GetTargetBranch() == target {
198+ for i := range res.MergeRequests {
199+ if m := &res.MergeRequests[i]; m.Source == source && m.Target == target {
200200 return m, nil
201201 }
202202 }
203- if token = res.Msg.GetNextPageToken(); token == "" {
203+ if token = res.NextPageToken; token == "" {
204204 break
205205 }
206206 }
M internal/adapters/web/components.templ +3 -2
@@ -22,9 +22,10 @@ templ CloneBlock(c Clone) {
2222 <button
2323 type="button"
2424 class="btn copy"
25+ data-js
2526 data-label="Copy"
2627 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)"
2829 >Copy</button>
2930 </div>
3031 }
@@ -76,7 +77,7 @@ templ ReadmeCard(r Readme) {
7677 // ReadmeView is a rendered README. Its HTML comes from the Markdown
7778 // renderer, which drops raw HTML and filters URLs.
7879 templ ReadmeView(r Readme) {
79- <article class="prose" aria-label={ r.Name }>
80+ <article class="prose" aria-label={ r.Name } hx-ignore>
8081 if r.Note != "" {
8182 <p class="dim">{ r.Note }</p>
8283 } 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 (
1313
1414 "github.com/a-h/templ"
1515
16- pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
1717 )
1818
1919 // Seg is a piece of a line of code: plain text, or the visible marker of an
@@ -101,11 +101,11 @@ type DiffFile struct {
101101 Hunks []DiffHunk
102102 }
103103
104-func lineNumber(n int32) string {
104+func lineNumber(n int) string {
105105 if n <= 0 {
106106 return ""
107107 }
108- return itoa(int(n))
108+ return itoa(n)
109109 }
110110
111111 // segments splits a line of code into plain text and markers. One trailing
@@ -197,79 +197,79 @@ func changeBar(add, del, most int) Bar {
197197 }
198198
199199 // statusLetter is the one-letter status of a file.
200-func statusLetter(s pb.FileStatus) string {
200+func statusLetter(s domain.FileStatus) string {
201201 switch s {
202- case pb.FileStatus_FILE_STATUS_ADDED:
202+ case domain.FileAdded:
203203 return "A"
204- case pb.FileStatus_FILE_STATUS_DELETED:
204+ case domain.FileDeleted:
205205 return "D"
206- case pb.FileStatus_FILE_STATUS_RENAMED:
206+ case domain.FileRenamed:
207207 return "R"
208- case pb.FileStatus_FILE_STATUS_COPIED:
208+ case domain.FileCopied:
209209 return "C"
210- case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
210+ case domain.FileTypeChanged:
211211 return "T"
212212 }
213213 return "M"
214214 }
215215
216216 // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
217-func hunkHeader(h *pb.Hunk) string {
218- s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines())
219- if t := h.GetHeader(); t != "" {
217+func hunkHeader(h domain.Hunk) string {
218+ s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
219+ if t := h.Header; t != "" {
220220 s += " " + visibleText(strings.TrimSpace(t))
221221 }
222222 return s
223223 }
224224
225-// diffLine converts a line of the API.
226-func diffLine(l *pb.Line) DiffLine {
227- dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())}
228- switch l.GetKind() {
229- case pb.LineKind_LINE_KIND_ADDITION:
230- dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber())
231- case pb.LineKind_LINE_KIND_DELETION:
232- dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber())
225+// diffLine converts a line of a hunk.
226+func diffLine(l domain.Line) DiffLine {
227+ dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber}
228+ switch l.Kind {
229+ case domain.LineAddition:
230+ dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber)
231+ case domain.LineDeletion:
232+ dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber)
233233 default:
234234 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)
236236 }
237237 return dl
238238 }
239239
240240 // diffFiles converts the files of a diff for the page.
241-func diffFiles(files []*pb.FileDiff) []DiffFile {
241+func diffFiles(files []domain.FileDiff) []DiffFile {
242242 most := 0
243243 for _, f := range files {
244- most = max(most, int(f.GetAdditions()+f.GetDeletions()))
244+ most = max(most, f.Additions+f.Deletions)
245245 }
246246 out := make([]DiffFile, 0, len(files))
247247 for i, f := range files {
248248 df := DiffFile{
249- ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()),
250- Add: int(f.GetAdditions()), Del: int(f.GetDeletions()),
251- Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most),
249+ ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
250+ Add: f.Additions, Del: f.Deletions,
251+ Bar: changeBar(f.Additions, f.Deletions, most),
252252 // A file that is shown in full opens; a file with nothing to read
253253 // (binary, truncated) stays closed under its note.
254- Open: len(f.GetHunks()) > 0 && !f.GetTruncated(),
254+ Open: len(f.Hunks) > 0 && !f.Truncated,
255255 }
256- df.Key = f.GetNewPath()
257- switch f.GetStatus() {
258- case pb.FileStatus_FILE_STATUS_DELETED:
259- df.Key = f.GetOldPath()
260- df.Path = visibleText(f.GetOldPath())
261- case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED:
262- df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath())
256+ df.Key = f.NewPath
257+ switch f.Status {
258+ case domain.FileDeleted:
259+ df.Key = f.OldPath
260+ df.Path = visibleText(f.OldPath)
261+ case domain.FileRenamed, domain.FileCopied:
262+ df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath)
263263 default:
264- df.Path = visibleText(f.GetNewPath())
264+ df.Path = visibleText(f.NewPath)
265265 }
266266 df.Note = fileNote(f)
267- if f.GetBinary() {
267+ if f.Binary {
268268 df.Bar = Bar{Rest: "bin"}
269269 }
270- for _, h := range f.GetHunks() {
270+ for _, h := range f.Hunks {
271271 dh := DiffHunk{Header: hunkHeader(h)}
272- for _, l := range h.GetLines() {
272+ for _, l := range h.Lines {
273273 dh.Lines = append(dh.Lines, diffLine(l))
274274 }
275275 dh.Split = pairLines(dh.Lines)
@@ -282,23 +282,23 @@ func diffFiles(files []*pb.FileDiff) []DiffFile {
282282
283283 // fileNote says what the hunks cannot: the kind of change when there is no
284284 // 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 {
286286 var parts []string
287287 switch {
288- case f.GetBinary():
288+ case f.Binary:
289289 parts = append(parts, "binary file: no text diff")
290- case f.GetTruncated():
291- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions()))
290+ case f.Truncated:
291+ parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
292292 }
293- if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() {
294- parts = append(parts, fmt.Sprintf("mode %06o → %06o", 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.OldMode, f.NewMode))
295295 }
296- switch f.GetStatus() {
297- case pb.FileStatus_FILE_STATUS_RENAMED:
298- parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity()))
299- case pb.FileStatus_FILE_STATUS_COPIED:
300- parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity()))
301- case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
296+ switch f.Status {
297+ case domain.FileRenamed:
298+ parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity))
299+ case domain.FileCopied:
300+ parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity))
301+ case domain.FileTypeChanged:
302302 parts = append(parts, "type changed")
303303 }
304304 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) {
106106 aria-current="true"
107107 }
108108 if p.Both {
109- hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))"
110- :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" }
109+ data-fire={ view }
110+ :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false"
111111 }
112112 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>
113113 }
@@ -115,7 +115,7 @@ templ viewLink(p DiffView, view, label, key, href string) {
115115 // collapseButton folds every file of the diff, or opens them all again when
116116 // none is open; its label follows the state.
117117 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">
119119 <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>
120120 </button>
121121 }
@@ -142,7 +142,6 @@ templ FileList(files []DiffFile) {
142142 <li>
143143 <a
144144 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 })()" }
146145 hx-on:click="document.getElementById(this.hash.slice(1)).open = true"
147146 >
148147 <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 (
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/require"
1212
13- pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
1414 )
1515
1616 // Trojan Source (ADR 0017): direction controls, zero-width and control
1717 // characters are shown as markers; ordinary text, tabs and non-Latin letters
1818 // are not touched.
1919 func TestSegmentsShowInvisibleCharacters(t *testing.T) {
20+ t.Parallel()
2021 tests := []struct {
2122 name, in string
2223 want []Seg
@@ -35,6 +36,7 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) {
3536 }
3637 for _, tt := range tests {
3738 t.Run(tt.name, func(t *testing.T) {
39+ t.Parallel()
3840 assert.Equal(t, tt.want, segments(tt.in))
3941 })
4042 }
@@ -43,17 +45,18 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) {
4345 assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n"))
4446 }
4547
46-func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line {
47- return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
48+func line(kind domain.LineKind, oldN, newN int, text string) domain.Line {
49+ return domain.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
4850 }
4951
5052 // The split view lines a run of deletions up with the additions after it;
5153 // context fills both sides; the extra lines of the longer run have an empty
5254 // opposite side.
5355 func TestPairLines(t *testing.T) {
54- ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION
56+ t.Parallel()
57+ ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion
5558 var ls []DiffLine
56- for _, l := range []*pb.Line{
59+ for _, l := range []domain.Line{
5760 line(ctx, 1, 1, "c"),
5861 line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"),
5962 line(add, 0, 2, "a1"),
@@ -83,6 +86,7 @@ func TestPairLines(t *testing.T) {
8386 }
8487
8588 func TestChangeBar(t *testing.T) {
89+ t.Parallel()
8690 assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0))
8791 assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100))
8892 assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100))
@@ -91,19 +95,41 @@ func TestChangeBar(t *testing.T) {
9195 }
9296
9397 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}))
95- assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755}))
96- assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change")
97- assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}))
98- assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large")
98+ t.Parallel()
99+ tests := []struct {
100+ name string
101+ file domain.FileDiff
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+ }
99124 }
100125
101126 func TestDiffFilesPaths(t *testing.T) {
102- fs := diffFiles([]*pb.FileDiff{
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")}}}},
104- {Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1},
105- {Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"},
106- {Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000},
127+ t.Parallel()
128+ fs := diffFiles([]domain.FileDiff{
129+ {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
130+ {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
131+ {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
132+ {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
107133 })
108134 assert.Equal(t, "a.go", fs[0].Path)
109135 assert.True(t, fs[0].Open)
@@ -117,8 +143,9 @@ func TestDiffFilesPaths(t *testing.T) {
117143 // A code cell holds exactly the text: escaped, with markers, and nothing
118144 // added between the pieces (the cell keeps its white space).
119145 func TestCodeLine(t *testing.T) {
146+ t.Parallel()
120147 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})
122149 require.NoError(t, codeLine(ln).Render(context.Background(), &b))
123150 assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+
124151 `<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 (
66 "bytes"
77 "context"
88 "errors"
9+ "fmt"
910 "net/http"
1011 "net/http/httptest"
1112 "regexp"
1213 "testing"
1314
14- "connectrpc.com/connect"
1515 "github.com/bnema/zerowrap"
1616 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
1718 "github.com/stretchr/testify/require"
1819
19- pb "bnema.dev/ermite-api/gen/ermite"
20- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
2123 )
2224
23-// failing is a repository client that fails every call with err.
24-type failing struct {
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-
25+// serve asks the web for path while every read of a repository fails with err,
26+// and returns the answer and what was logged.
3727 func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) {
3828 t.Helper()
39- srv, nerr := New(Config{
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)
29+ srv := newTestServer(t, Config{Repos: reposFailing(t, err), Browse: mocks.NewBrowseService(t)})
4430 var logs bytes.Buffer
4531 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
4632 rec := httptest.NewRecorder()
@@ -48,37 +34,146 @@ func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, st
4834 return rec, logs.String()
4935 }
5036
51-// Errors of the API: not found (and an invalid name) are the 404 page, any
52-// other is the 500 page with an id that the log carries.
37+// Errors of the use cases: not found (and an invalid name) are the 404 page, a
38+// visitor that left gets nothing, any other is the 500 page with an id that
39+// the log carries.
5340 func TestErrorPages(t *testing.T) {
54- rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone")
55- assert.Equal(t, http.StatusNotFound, rec.Code)
56- assert.Contains(t, rec.Body.String(), "not found")
57- assert.Empty(t, logs, "a 404 is not an error")
41+ t.Parallel()
42+ tests := []struct {
43+ name string
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+}
5881
59- rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad")
60- assert.Equal(t, http.StatusNotFound, rec.Code)
61- assert.NotContains(t, rec.Body.String(), "bad name")
82+// A visitor that left gets no page at all.
83+func TestCanceledRequestGetsNoPage(t *testing.T) {
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)
62136
63- secret := "dial tcp 10.0.0.5: kv exploded"
64- rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo")
65- assert.Equal(t, http.StatusInternalServerError, rec.Code)
66- assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
67- assert.Contains(t, rec.Body.String(), "internal error")
68- assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log")
69- m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String())
70- require.Len(t, m, 2, "the page shows a request id")
71- assert.Contains(t, logs, m[1], "the log carries the same id")
72- assert.Contains(t, logs, "kv exploded")
137+ if tt.want.IsOwner() {
138+ assert.Equal(t, http.StatusOK, rec.Code)
139+ return
140+ }
141+ assert.Equal(t, http.StatusNotFound, rec.Code, "a private repository is a missing one")
142+ assert.Contains(t, rec.Body.String(), "not found")
143+ })
144+ }
145+}
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)})
73154
74- rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/")
75- assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too")
155+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Root())
156+ rec := httptest.NewRecorder()
157+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/secret", nil))
158+ assert.Equal(t, http.StatusNotFound, rec.Code)
76159 }
77160
78161 func TestNewValidates(t *testing.T) {
79- _, err := New(Config{PublicURL: "https://forge.example"})
80- assert.Error(t, err, "clients are required")
81- git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid")
82- _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"})
83- assert.Error(t, err)
162+ t.Parallel()
163+ tests := []struct {
164+ name string
165+ cfg Config
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+ }
84179 }
M internal/adapters/web/events.go +74 -45
@@ -5,22 +5,25 @@ package web
55 import (
66 "context"
77 "errors"
8+ "fmt"
89 "io"
910 "net/http"
1011 "net/url"
1112 "strconv"
1213 "strings"
14+ "sync"
1315 "time"
1416
15- "connectrpc.com/connect"
1617 "github.com/bnema/zerowrap"
1718
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"
1922 )
2023
2124 // Live updates. A page that can change under the eyes of its reader opens
2225 // GET /-/events (the Live component of the hint badge). The handler follows
23-// EventService.Watch as the visitor of the request and turns each event into
26+// EventService.Watch as the principal of the request and turns each event into
2427 // the HTML fragments the page shows, sent as Server-Sent Events:
2528 //
2629 // GET /-/events?topic=home
@@ -51,7 +54,10 @@ import (
5154 // changed: the page fetches itself again (ermiteLive in ermite.js)
5255 //
5356 // 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.
5561 // An event that changes nothing on the page is still sent as an id alone, so
5662 // that a reconnection resumes after it.
5763
@@ -60,10 +66,6 @@ import (
6066 const DefaultHeartbeat = 25 * time.Second
6167
6268 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
6769 // writeWindow bounds one write to the visitor, whatever the write timeout
6870 // of the server is.
6971 writeWindow = 30 * time.Second
@@ -102,7 +104,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
102104 if repo != "" {
103105 // A repository the visitor cannot see is a missing one (AC2).
104106 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)
106108 ccancel()
107109 if err != nil {
108110 s.denyEvents(ctx, w, err)
@@ -110,29 +112,44 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
110112 }
111113 }
112114
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{
114124 Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
115- }))
116- if err != nil {
117- s.denyEvents(ctx, w, err)
118- return
125+ // The visitor is the client of the watch, for the limit per client.
126+ Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
127+ Ready: start,
119128 }
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{})
125129 go func() {
126- defer close(done)
130+ defer close(finished)
127131 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()
129145 select {
130- case msgs <- recvd{ev: stream.Msg().GetEvent()}:
146+ case msgs <- recvd{ev: we}:
147+ return nil
131148 case <-ctx.Done():
132- return
149+ return ctx.Err()
133150 }
134- }
135- if err := stream.Err(); err != nil {
151+ })
152+ if err != nil && ctx.Err() == nil {
136153 select {
137154 case msgs <- recvd{err: err}:
138155 case <-ctx.Done():
@@ -141,26 +158,24 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
141158 }()
142159 defer func() {
143160 cancel()
144- _ = stream.Close()
145- <-done
161+ <-finished
146162 }()
147163
164+ // The watch is registered (or refused, which ends it before): only then
165+ // does the answer commit to 200.
148166 var first *recvd
149- grace := time.NewTimer(startGrace)
150167 select {
168+ case <-started:
151169 case m, open := <-msgs:
152- grace.Stop()
153170 if !open {
154171 return // the server is stopping
155172 }
173+ if m.err != nil {
174+ s.denyEvents(ctx, w, m.err)
175+ return
176+ }
156177 first = &m
157- case <-grace.C:
158178 case <-ctx.Done():
159- grace.Stop()
160- return
161- }
162- if first != nil && first.err != nil {
163- s.denyEvents(ctx, w, first.err)
164179 return
165180 }
166181
@@ -182,11 +197,14 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
182197 st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")}
183198 handle := func(m recvd) bool {
184199 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) {
186201 log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended")
187202 }
188203 return false
189204 }
205+ if !liveEvent(m.ev) {
206+ return true // nothing to show, not even an id to resume after
207+ }
190208 // An owner's stream ends when the session does, before it sends
191209 // anything more: an event can carry what only the owner may see, and
192210 // waiting for the next heartbeat would leave a window.
@@ -205,7 +223,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
205223 log.Warn().Err(err).Str("topic", topic).Msg("render live fragment")
206224 frags = gap
207225 }
208- return out.send(m.ev.GetId(), frags) == nil
226+ return out.send(m.ev.ID, frags) == nil
209227 }
210228 if first != nil && !handle(*first) {
211229 return
@@ -240,30 +258,41 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
240258
241259 // recvd is a message of the Watch stream, or how it failed.
242260 type recvd struct {
243- ev *pb.Event
261+ ev ports.WatchEvent
244262 err error
245263 }
246264
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+
247276 // denyStream answers a request that gets no stream.
248277 func denyStream(w http.ResponseWriter, status int, msg string) {
249278 w.Header().Set("Cache-Control", "no-store")
250279 http.Error(w, msg, status)
251280 }
252281
253-// denyEvents maps an error of the API, before the stream started, to a
282+// denyEvents maps an error of a use case, before the stream started, to a
254283 // status. Not found and invalid are 404 whatever the reason: a private
255284 // repository is a missing one.
256285 func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) {
257- switch code := connect.CodeOf(err); {
258- case code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
286+ switch {
287+ case errors.Is(err, context.Canceled):
288+ // The visitor is gone.
289+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
259290 denyStream(w, http.StatusNotFound, "not found")
260- case code == connect.CodeResourceExhausted:
291+ case errors.Is(err, domain.ErrResourceExhausted):
261292 w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
262293 denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later")
263- case code == connect.CodeUnauthenticated:
294+ case errors.Is(err, domain.ErrUnauthenticated):
264295 denyStream(w, http.StatusUnauthorized, "unauthorized")
265- case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
266- // The visitor is gone.
267296 default:
268297 log := zerowrap.FromCtx(ctx)
269298 log.Error().Err(err).Msg("open event stream")
M internal/adapters/web/events_test.go +155 -96
@@ -6,6 +6,7 @@ import (
66 "bufio"
77 "context"
88 "errors"
9+ "fmt"
910 "io"
1011 "net/http"
1112 "net/http/httptest"
@@ -14,15 +15,17 @@ import (
1415 "testing"
1516 "time"
1617
17- "connectrpc.com/connect"
1818 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
1920 "github.com/stretchr/testify/require"
2021
21- pb "bnema.dev/ermite-api/gen/ermite"
22- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/ports/mocks"
2325 )
2426
2527 func TestFormatEvent(t *testing.T) {
28+ t.Parallel()
2629 assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n",
2730 formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line")
2831 assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true))
@@ -32,6 +35,7 @@ func TestFormatEvent(t *testing.T) {
3235 }
3336
3437 func TestParseTopic(t *testing.T) {
38+ t.Parallel()
3539 for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} {
3640 repo, ok := parseTopic(in)
3741 assert.True(t, ok, in)
@@ -44,38 +48,57 @@ func TestParseTopic(t *testing.T) {
4448 }
4549
4650 func TestLiveURL(t *testing.T) {
51+ t.Parallel()
4752 assert.Equal(t, "/-/events?topic=home", liveURL("home", ""))
4853 assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs"))
4954 }
5055
5156 func TestNewCommitsText(t *testing.T) {
57+ t.Parallel()
5258 assert.Equal(t, "1 new commit — reload", newCommits("1", 1))
5359 assert.Equal(t, "3 new commits — reload", newCommits("3", 3))
5460 assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2))
5561 }
5662
57-// fakeEvents is an EventService whose behaviour a test sets.
58-type fakeEvents struct {
59- ermiteconnect.UnimplementedEventServiceHandler
63+// watchProbe drives the generated EventService mock: what the watch does, and
64+// a record of the principal and the parameters of the watches it was asked for.
65+type watchProbe struct {
6066 err error
61- events []*pb.Event
67+ events []ports.WatchEvent
6268 opened atomic.Int32
6369 closed atomic.Int32
64- resume atomic.Value
70+ resume atomic.Value // string: ResumeAfter of the last watch
71+ topics atomic.Value // []string: Topics of the last watch
72+ client atomic.Value // string: Client of the last watch
73+ who atomic.Value // domain.Principal: caller of the last watch
6574 release chan struct{}
66- push chan *pb.Event // events sent on demand, after the fixed ones
75+ push chan ports.WatchEvent // events sent on demand, after the fixed ones
6776 }
6877
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 {
7088 f.opened.Add(1)
7189 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)
7394 if f.err != nil {
7495 return f.err
7596 }
76- _ = st.Send(nil)
97+ if params.Ready != nil {
98+ params.Ready()
99+ }
77100 for _, ev := range f.events {
78- if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil {
101+ if err := send(ev); err != nil {
79102 return err
80103 }
81104 }
@@ -86,46 +109,29 @@ func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchReq
86109 case <-f.release:
87110 return nil
88111 case ev := <-f.push:
89- if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil {
112+ if err := send(ev); err != nil {
90113 return err
91114 }
92115 }
93116 }
94117 }
95118
96-// repoOK answers every GetRepo with a public repository.
97-type repoOK struct {
98- ermiteconnect.UnimplementedRepoServiceHandler
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 {
119+// eventsServer serves the web on a fake EventService, with a write timeout
120+// shorter than the heartbeat, like a server that sets one.
121+func eventsServer(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration) *httptest.Server {
108122 t.Helper()
109123 return eventsServerWith(t, f, beat, writeTimeout, nil)
110124 }
111125
112126 // 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 {
114128 t.Helper()
115- api := http.NewServeMux()
116- api.Handle(ermiteconnect.NewEventServiceHandler(f))
117- api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{}))
118- backend := httptest.NewServer(api)
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",
129+ srv := newTestServer(t, Config{
130+ Repos: reposServing(t, publicRepo(t, "x")),
131+ Browse: mocks.NewBrowseService(t),
132+ Events: f.service(t),
126133 Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess),
127134 })
128- require.NoError(t, err)
129135 ts := httptest.NewUnstartedServer(srv)
130136 ts.Config.WriteTimeout = writeTimeout
131137 ts.Start()
@@ -150,7 +156,8 @@ func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string
150156 // An idle stream gets a comment every heartbeat, and the write timeout of the
151157 // server does not end it.
152158 func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) {
153- f := &fakeEvents{release: make(chan struct{})}
159+ t.Parallel()
160+ f := &watchProbe{release: make(chan struct{})}
154161 ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond)
155162 resp, cancel := open(t, ts, "topic=home", nil)
156163 require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -173,9 +180,10 @@ func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) {
173180 // The id of the event, its name and the lines of its data come through; the
174181 // Last-Event-ID header is the resume point; a Gap becomes a gap event.
175182 func TestEventsStreamsEventsAndResumes(t *testing.T) {
176- f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{
177- {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}},
178- {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}},
183+ t.Parallel()
184+ f := &watchProbe{release: make(chan struct{}), events: []ports.WatchEvent{
185+ {ID: "b-7", Event: domain.PushEvent{Repo: repoName(t, "x")}},
186+ {Gap: true},
179187 }}
180188 ts := eventsServer(t, f, time.Hour, 0)
181189 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) {
191199 assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id")
192200 assert.Contains(t, lines, "event: gap")
193201 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")
194205 }
195206
196-// Too many watchers is a 503 the browser can retry, not a stream.
197-func TestEventsResourceExhausted(t *testing.T) {
198- f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))}
199- ts := eventsServer(t, f, time.Hour, 0)
200- resp, _ := open(t, ts, "topic=home", nil)
201- assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
202- assert.NotEmpty(t, resp.Header.Get("Retry-After"))
203- assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream")
207+// Too many watchers is a 503 the browser can retry, not a stream; what the
208+// watch is refused for decides the status.
209+func TestEventsRefusals(t *testing.T) {
210+ t.Parallel()
211+ tests := []struct {
212+ name string
213+ err error
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+}
204235
205- f.err = connect.NewError(connect.CodeNotFound, errors.New("x"))
206- resp, _ = open(t, ts, "topic=home", nil)
207- assert.Equal(t, http.StatusNotFound, resp.StatusCode)
208- f.err = errors.New("boom")
209- resp, _ = open(t, ts, "topic=home", nil)
236+// A panic in the watch runs outside the handler goroutine, where net/http
237+// would not recover it: it ends the stream with a 500 instead of the process.
238+func TestEventsWatchPanicIsAnError(t *testing.T) {
239+ t.Parallel()
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)
210252 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
211253 }
212254
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+
213283 // The handler returns, and the watch it opened ends, when the visitor goes
214284 // away or the API ends the stream.
215285 func TestEventsNoLeak(t *testing.T) {
216- f := &fakeEvents{release: make(chan struct{})}
286+ t.Parallel()
287+ f := &watchProbe{release: make(chan struct{})}
217288 ts := eventsServer(t, f, time.Hour, 0)
218289
219290 resp, cancel := open(t, ts, "topic=home", nil)
@@ -236,13 +307,9 @@ func TestEventsNoLeak(t *testing.T) {
236307 require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond)
237308 }
238309
239-func TestEventsOffWithoutClient(t *testing.T) {
240- srv, err := New(Config{
241- Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"),
242- Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"),
243- PublicURL: "https://forge.example",
244- })
245- require.NoError(t, err)
310+func TestEventsOffWithoutEventService(t *testing.T) {
311+ t.Parallel()
312+ srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
246313 rec := httptest.NewRecorder()
247314 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil))
248315 assert.Equal(t, http.StatusNotFound, rec.Code)
@@ -252,18 +319,10 @@ func TestEventsOffWithoutClient(t *testing.T) {
252319 // show it load themselves again: the summary and the refs page, whose rows
253320 // and marks depend on it. The commits page shows a branch, not the default.
254321 func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) {
255- api := http.NewServeMux()
256- api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{}))
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)
322+ t.Parallel()
323+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t)})
265324
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")}}
267326 for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} {
268327 ls := &liveStream{s: srv, repo: "x", view: view}
269328 got, err := ls.render(t.Context(), ev)
@@ -276,30 +335,28 @@ func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) {
276335 }
277336 }
278337
279-func limiterFor(sess SessionAuth) FailureLimiter {
338+func limiterFor(sess ports.WebSessionService) ports.FailureLimiter {
280339 if sess == nil {
281340 return nil
282341 }
283342 return &countingLimiter{}
284343 }
285344
286-// revocable is a SessionAuth whose one session ends when the test says so, like
287-// SignOutEverywhere does.
288-type revocable struct {
289- fakeSessions
290- ended atomic.Bool
291-}
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
345+// newRevocable is a ports.WebSessionService whose one session ends when the test sets
346+// ended, like SignOutEverywhere does.
347+func newRevocable(t *testing.T, valid string) (*mocks.WebSessionService, *atomic.Bool) {
348+ t.Helper()
349+ var ended atomic.Bool
350+ return newVerifier(t, valid, func(tok string) (bool, error) { return tok == valid && !ended.Load(), nil }), &ended
295351 }
296352
297353 // GO-002: the stream of the owner is checked before each event is sent, not
298354 // only on the heartbeat: after the session ended, the next event is not sent
299355 // and the stream ends.
300356 func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
301- f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)}
302- sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}}
357+ t.Parallel()
358+ f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
359+ sess, ended := newRevocable(t, "tok")
303360 ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test
304361 resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"})
305362 require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -318,7 +375,7 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
318375 // The stream is up; a first event goes through while the session lives.
319376 require.Equal(t, ": connected", line())
320377 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}
322379 var seen []string
323380 for l := line(); l != "data: gap"; l = line() {
324381 seen = append(seen, l)
@@ -327,9 +384,9 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
327384 assert.Contains(t, seen, "id: b-1")
328385
329386 // Signed out everywhere: the next event is not sent, the stream ends.
330- sess.ended.Store(true)
387+ ended.Store(true)
331388 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}:
333390 case <-time.After(5 * time.Second):
334391 t.Fatal("the event was not delivered to the web")
335392 }
@@ -340,13 +397,14 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
340397
341398 // A visitor's stream does not depend on sessions: events keep flowing.
342399 func TestEventsVisitorStreamIgnoresSessions(t *testing.T) {
343- f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)}
344- sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}}
345- sess.ended.Store(true)
400+ t.Parallel()
401+ f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
402+ sess, ended := newRevocable(t, "tok")
403+ ended.Store(true)
346404 ts := eventsServerWith(t, f, time.Hour, 0, sess)
347405 resp, _ := open(t, ts, "topic=home", nil)
348406 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}
350408 sc := bufio.NewScanner(resp.Body)
351409 found := false
352410 for sc.Scan() {
@@ -361,8 +419,9 @@ func TestEventsVisitorStreamIgnoresSessions(t *testing.T) {
361419 // GO-006: a stream opened with an invalid cookie is anonymous, and its headers
362420 // say the answer must not be stored and varies on the cookie.
363421 func TestEventsInvalidCookieStream(t *testing.T) {
364- f := &fakeEvents{release: make(chan struct{})}
365- ts := eventsServerWith(t, f, time.Hour, 0, &fakeSessions{valid: "tok"})
422+ t.Parallel()
423+ f := &watchProbe{release: make(chan struct{})}
424+ ts := eventsServerWith(t, f, time.Hour, 0, newSessions(t, "tok"))
366425 resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"})
367426 require.Equal(t, http.StatusOK, resp.StatusCode)
368427 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
55 import (
66 "bytes"
77 "context"
8+ "errors"
89 "strconv"
10+ "sync"
11+ "time"
912
10- "connectrpc.com/connect"
1113 "github.com/a-h/templ"
1214
13- pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
1417 )
1518
1619 // 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
3437
3538 var gap = []fragment{{event: "gap", html: "gap"}}
3639
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+
3755 // render returns the fragments for an event, none when it changes nothing on
3856 // the page.
39-func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) {
40- if ev == nil {
41- return nil, nil
42- }
43- if ev.GetGap() != nil {
57+func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
58+ if we.Gap {
4459 return gap, nil
4560 }
61+ if we.Event == nil {
62+ return nil, nil
63+ }
4664 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
4876 }
49- return l.repoPage(ctx, ev)
77+ return l.s.repoView(ctx, name)
5078 }
5179
5280 // home upserts the row of the repository an event is about. The row is read
53-// again from the API as the visitor of the stream, so a repository that was
54-// deleted or that the visitor can no longer see (public to private) removes
55-// 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) {
81+// again from the use case as the principal of the stream, so a repository
82+// that was deleted or that the visitor can no longer see (public to private)
83+// removes the row, and the stream never carries more than the list shows.
84+func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
5785 var name string
5886 fresh := false
59- switch k := ev.GetKind().(type) {
60- case *pb.Event_RepoDeleted:
61- return l.gone(ctx, k.RepoDeleted.GetRepo())
62- case *pb.Event_RepoCreated:
63- name = k.RepoCreated.GetRepo().GetName()
64- case *pb.Event_RepoUpdated:
65- name = k.RepoUpdated.GetRepo().GetName()
66- case *pb.Event_Pushed:
67- name, fresh = k.Pushed.GetRepo(), true
87+ switch ev := we.Event.(type) {
88+ case domain.RepoDeleted:
89+ return l.gone(ctx, ev.Repo.String())
90+ case domain.RepoCreated:
91+ name = ev.Repo.String()
92+ case domain.RepoUpdated:
93+ name = ev.Repo.String()
94+ case domain.PushEvent:
95+ name, fresh = ev.Repo.String(), true
6896 default:
6997 return nil, nil
7098 }
71- res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
99+ view, err := l.repoByName(ctx, name)
72100 switch {
73- case connect.CodeOf(err) == connect.CodeNotFound:
101+ case errors.Is(err, domain.ErrNotFound):
74102 return l.gone(ctx, name)
75103 case err != nil:
76104 return nil, err
77105 }
78- row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false)
106+ row := homeRow(view, l.s.cfg.Now(), false)
79107 row.Fresh = fresh
80108 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
81109 return []fragment{f}, err
@@ -87,16 +115,16 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
87115 }
88116
89117 // repoPage handles the events of the topic of one repository.
90-func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) {
91- switch k := ev.GetKind().(type) {
92- case *pb.Event_RepoDeleted, *pb.Event_RepoCreated:
118+func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
119+ switch ev := we.Event.(type) {
120+ case domain.RepoDeleted, domain.RepoCreated:
93121 // Gone, or a new repository under the same name: whatever the page
94122 // shows is not true any more.
95123 return gap, nil
96- case *pb.Event_RepoUpdated:
97- _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
124+ case domain.RepoUpdated:
125+ _, err := l.repoByName(ctx, l.repo)
98126 switch {
99- case connect.CodeOf(err) == connect.CodeNotFound:
127+ case errors.Is(err, domain.ErrNotFound):
100128 return gap, nil // no longer visible
101129 case err != nil:
102130 return nil, err
@@ -104,26 +132,26 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er
104132 return gap, nil // the description, the default branch
105133 }
106134 return nil, nil
107- case *pb.Event_MergeRequestOpened:
108- return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload")
109- case *pb.Event_MergeRequestMerged:
110- return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload")
111- case *pb.Event_MergeRequestClosed:
112- return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload")
113- case *pb.Event_CommentAdded:
114- return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload")
115- case *pb.Event_Pushed:
135+ case domain.MergeRequestOpened:
136+ return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
137+ case domain.MergeRequestMerged:
138+ return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
139+ case domain.MergeRequestClosed:
140+ return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
141+ case domain.CommentAdded:
142+ return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
143+ case domain.PushEvent:
116144 switch l.view {
117145 case "mr":
118- return l.mrPushed(ctx, k.Pushed)
146+ return l.mrPushed(ctx, ev)
119147 case "mrs":
120148 return nil, nil
121149 }
122150 switch l.view {
123151 case "refs":
124- return l.refs(ctx)
152+ return l.refs(ctx, we.ID)
125153 case "commits":
126- return l.banner(ctx, k.Pushed)
154+ return l.banner(ctx, ev)
127155 }
128156 return l.lastPush(ctx)
129157 }
@@ -132,39 +160,144 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er
132160
133161 // lastPush is the "last push" fact of the summary.
134162 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)
136164 if err != nil {
137165 return nil, err
138166 }
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())))
140168 return []fragment{f}, err
141169 }
142170
143-// refs is the table of the refs page and the facts of its rail.
144-func (l *liveStream) refs(ctx context.Context) ([]fragment, error) {
145- d, err := l.s.loadRepo(ctx, l.repo)
146- if err != nil {
147- return nil, err
171+// refs is the table of the refs page and the facts of its rail. The fragments
172+// of one event are built once for all the watchers of the repository that
173+// see the same things (see fanout); eventID names the event.
174+func (l *liveStream) refs(ctx context.Context, eventID string) ([]fragment, error) {
175+ key := fanKey{repo: l.repo, event: eventID, owner: SignedIn(ctx)}
176+ return l.s.refsFan.do(ctx, key, func() ([]fragment, error) {
177+ d, err := l.s.loadRepo(ctx, l.repo)
178+ if err != nil {
179+ return nil, err
180+ }
181+ p := l.s.refsPage(ctx, d)
182+ body, err := renderFragment(ctx, "refs-list", RefsBody(p))
183+ if err != nil {
184+ return nil, err
185+ }
186+ kv, err := renderFragment(ctx, "refs-list", RefsKV(p))
187+ return []fragment{body, kv}, err
188+ })
189+}
190+
191+const (
192+ // fanTTL is how long the fragments of an event are kept for the watchers
193+ // that are told of it a little later than the first.
194+ fanTTL = 2 * time.Second
195+ // fanMax bounds the events kept; past it an event is built for each
196+ // watcher, as without sharing.
197+ fanMax = 64
198+)
199+
200+// fanKey says which watchers may share the fragments of an event: those of
201+// the same repository, for the same event, and of the same class. The class
202+// is anonymous or owner (a session is the owner's alone, ADR 0018): what is
203+// built as the owner can show what an anonymous visitor may not see, so the
204+// two never share an entry.
205+type fanKey struct {
206+ repo, event string
207+ owner bool
208+}
209+
210+type fanEntry struct {
211+ done chan struct{} // closed once frags or err is set
212+ frags []fragment
213+ err error
214+ at time.Time // when it was built; zero while it is
215+}
216+
217+// fanout builds the fragments of an event once for the many watchers that
218+// receive it: a push to a repository that 256 pages watch would otherwise
219+// load the refs and the commits they designate 256 times. The zero value is
220+// ready. A failure is never kept: the next watcher builds it again.
221+type fanout struct {
222+ mu sync.Mutex
223+ m map[fanKey]*fanEntry
224+}
225+
226+// do returns the fragments of key, built by build when nobody has or is
227+// building them. build runs with the context of the watcher that calls it,
228+// which is of the class of the key; a watcher whose builder failed (its
229+// request was cancelled, say) does not fail the others, they build again.
230+func (f *fanout) do(ctx context.Context, key fanKey, build func() ([]fragment, error)) ([]fragment, error) {
231+ if key.event == "" {
232+ return build() // no event to share by
148233 }
149- p := l.s.refsPage(ctx, d)
150- body, err := renderFragment(ctx, "refs-list", RefsBody(p))
151- if err != nil {
152- return nil, err
234+ for range 3 {
235+ f.mu.Lock()
236+ now := time.Now()
237+ for k, e := range f.m {
238+ if !e.at.IsZero() && now.Sub(e.at) > fanTTL {
239+ delete(f.m, k)
240+ }
241+ }
242+ e, shared := f.m[key]
243+ if !shared {
244+ if len(f.m) >= fanMax {
245+ f.mu.Unlock()
246+ return build()
247+ }
248+ if f.m == nil {
249+ f.m = make(map[fanKey]*fanEntry)
250+ }
251+ e = &fanEntry{done: make(chan struct{})}
252+ f.m[key] = e
253+ }
254+ f.mu.Unlock()
255+
256+ if !shared {
257+ return f.lead(key, e, build)
258+ }
259+ select {
260+ case <-e.done:
261+ case <-ctx.Done():
262+ return nil, ctx.Err()
263+ }
264+ if e.err == nil {
265+ return e.frags, nil
266+ }
153267 }
154- kv, err := renderFragment(ctx, "refs-list", RefsKV(p))
155- return []fragment{body, kv}, err
268+ return build()
269+}
270+
271+// lead builds the entry that the caller registered.
272+func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) (frags []fragment, err error) {
273+ err = errors.New("web: fragments were not built") // a panic of build
274+ defer func() {
275+ f.mu.Lock()
276+ e.frags, e.err, e.at = frags, err, time.Now()
277+ if err != nil && f.m[key] == e {
278+ delete(f.m, key)
279+ }
280+ f.mu.Unlock()
281+ close(e.done)
282+ }()
283+ return build()
156284 }
157285
158286 // banner tells the commits page how many commits its branch got since the
159287 // 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) {
161291 if l.ref == "" || !isFullID(l.head) {
162292 return nil, nil
163293 }
164294 tip, touched := "", false
165- for _, u := range p.GetRefUpdates() {
166- if u.GetRef() == "refs/heads/"+l.ref {
167- tip, touched = u.GetNewId(), true
295+ for _, u := range p.Updates {
296+ if u.Ref == "refs/heads/"+l.ref {
297+ tip, touched = u.New, true
298+ if u.IsDelete() {
299+ tip = ""
300+ }
168301 }
169302 }
170303 switch {
@@ -175,9 +308,11 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro
175308 }
176309 text := ""
177310 if tip != l.head {
178- res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
179- Repo: l.repo, Revision: tip, PageSize: pageSize,
180- }))
311+ name, err := domain.ParseRepoName(l.repo)
312+ if err != nil {
313+ return nil, err
314+ }
315+ res, err := l.s.commitList(ctx, name, tip, "", pageSize, "")
181316 if err != nil {
182317 return nil, err
183318 }
@@ -185,10 +320,10 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro
185320 // ordered by date, not as "head..tip", so commits that are not new may
186321 // be counted before the head is met. And one page of the log being
187322 // 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".
189324 n, found := 0, false
190- for _, c := range res.Msg.GetCommits() {
191- if c.GetId() == l.head {
325+ for _, c := range res.Commits {
326+ if c.ID.String() == l.head {
192327 found = true
193328 break
194329 }
@@ -197,13 +332,13 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro
197332 switch {
198333 case found:
199334 text = newCommits(strconv.Itoa(n), n)
200- case res.Msg.GetNextPageToken() != "":
335+ case res.NextPageToken != "":
201336 text = newCommits(strconv.Itoa(n)+"+", 2)
202337 default:
203338 text = "the branch was rewritten — reload"
204339 }
205340 }
206- c := CommitsSlot()
341+ c := LiveSlot("commits-head")
207342 if text != "" {
208343 c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
209344 }
@@ -240,12 +375,12 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
240375 }
241376
242377 // 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) {
244379 if l.ref == "" {
245380 return nil, nil
246381 }
247- for _, u := range p.GetRefUpdates() {
248- if u.GetRef() == "refs/heads/"+l.ref {
382+ for _, u := range p.Updates {
383+ if u.Ref == "refs/heads/"+l.ref {
249384 n, err := strconv.ParseInt(l.n, 10, 64)
250385 if err != nil {
251386 return nil, nil
M internal/adapters/web/fragments.templ +22 -11
@@ -55,31 +55,40 @@ templ RefsKV(p RefsPage) {
5555 </dl>
5656 }
5757
58-// CommitsSlot is the place of the banner of the commits page, empty.
59-templ CommitsSlot() {
60- <div id="commits-head"></div>
58+// LiveSlot is the place of a live banner, empty: a status region that stays
59+// in the page. The live updates (ermiteLive in ermite.js) replace its children,
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>
6170 }
6271
6372 // CommitsBanner announces new commits on the branch the page lists. It links
6473 // to the first page of the branch.
6574 templ CommitsBanner(text, href string) {
66- <div id="commits-head" class="live-banner" role="status">
67- <a href={ templ.SafeURL(href) }>{ text }</a>
68- </div>
75+ @liveBanner("commits-head", text, href)
6976 }
7077
71-// MRNotice tells a merge request page that it is out of date. The empty slot
72-// of the page has the same id, so the first notice replaces it.
78+// MRNotice tells a merge request page that it is out of date, or the list that
79+// its content changed.
7380 templ MRNotice(text, href string) {
74- <div id="mr-notice" class="live-banner" role="status">
75- <a href={ templ.SafeURL(href) }>{ text }</a>
76- </div>
81+ @liveBanner("mr-notice", text, href)
7782 }
7883
7984 // Live is the connection to the event stream, in the hint badge. It shows
8085 // only once a connection was lost: a page that never connected, or that is
8186 // live, says nothing. The events of the stream are fragments that ermiteLive
8287 // (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.
8392 templ Live(l Layout) {
8493 if l.Live && l.LiveURL != "" {
8594 <span
@@ -90,6 +99,8 @@ templ Live(l Layout) {
9099 :text="data.state === 'offline' ? 'live updates paused' : ''"
91100 hx-on="htmx:sse:after:connection -> data.state = 'live';
92101 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);
93104 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
94105 gap -> ermiteLive.reload()"
95106 ></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 @@
33 package web
44
55 import (
6+ "bytes"
67 "context"
78 "fmt"
89 "html"
@@ -109,7 +110,7 @@ const maxViewLines = 20000
109110
110111 // countLines is the number of lines of src, as the code view numbers them.
111112 func countLines(src []byte) int {
112- n := strings.Count(string(src), "\n")
113+ n := bytes.Count(src, []byte{'\n'})
113114 if len(src) > 0 && src[len(src)-1] != '\n' {
114115 n++
115116 }
@@ -158,18 +159,35 @@ func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.T
158159 }
159160 toks = append(toks, t)
160161 }
161- if strings.TrimRight(joinTokens(toks), "\n") != strings.TrimRight(src, "\n") {
162+ if !tokensMatch(toks, src) {
162163 return nil, false
163164 }
164165 return toks, true
165166 }
166167
167-func joinTokens(toks []chroma.Token) string {
168- var b strings.Builder
168+// tokensMatch reports whether the values of the tokens, joined, are src but
169+// for the line feeds that end either: it compares piece by piece, where
170+// joining the tokens first would copy the whole file.
171+func tokensMatch(toks []chroma.Token, src string) bool {
172+ want := strings.TrimRight(src, "\n")
173+ pos := 0
169174 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)
171189 }
172- return b.String()
190+ return pos == len(want)
173191 }
174192
175193 // 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 (
1111 "github.com/stretchr/testify/assert"
1212 "github.com/stretchr/testify/require"
1313
14- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
1515 )
1616
1717 func TestHighlightLines(t *testing.T) {
18+ t.Parallel()
1819 v := highlight(context.Background(), "a.go", "package main\n\nfunc main() {}\n")
1920 assert.Equal(t, 3, v.lines)
2021 assert.Equal(t, "Go", v.lang)
@@ -30,6 +31,7 @@ func TestHighlightLines(t *testing.T) {
3031 }
3132
3233 func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) {
34+ t.Parallel()
3335 v := highlight(context.Background(), "unknown.zzz", "<script>alert(1)</script> & \"q\"\n")
3436 assert.Equal(t, "", v.lang)
3537 h := string(v.html)
@@ -41,6 +43,7 @@ func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) {
4143 }
4244
4345 func TestHighlightMarksInvisibleCharacters(t *testing.T) {
46+ t.Parallel()
4447 for r, cp := range map[rune]string{
4548 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF",
4649 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F",
@@ -63,6 +66,7 @@ func TestHighlightMarksInvisibleCharacters(t *testing.T) {
6366 // The lexers do not lose or add characters: the text of the lines is the
6467 // source, whatever the language.
6568 func TestHighlightKeepsTheSource(t *testing.T) {
69+ t.Parallel()
6670 srcs := map[string]string{
6771 "a.go": "package a\n\nvar s = `multi\nline`\n// end",
6872 "b.py": "def f():\n '''doc\n string'''\n return 1\n",
@@ -93,6 +97,7 @@ func TestHighlightKeepsTheSource(t *testing.T) {
9397 }
9498
9599 func TestFirstLinesAndCount(t *testing.T) {
100+ t.Parallel()
96101 assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2))
97102 assert.Equal(t, "a\nb", firstLines("a\nb", 5))
98103 assert.Equal(t, 3, countLines([]byte("a\nb\nc")))
@@ -101,6 +106,7 @@ func TestFirstLinesAndCount(t *testing.T) {
101106 }
102107
103108 func TestHighlightCtxCancelled(t *testing.T) {
109+ t.Parallel()
104110 ctx, cancel := context.WithCancel(context.Background())
105111 cancel()
106112 v := highlightCtx(ctx, "a.go", "package a\n")
@@ -116,12 +122,14 @@ func (panicLexer) Tokenise(*chroma.TokeniseOptions, string) (chroma.Iterator, er
116122 }
117123
118124 func TestTokeniseRecoversFromALexerPanic(t *testing.T) {
125+ t.Parallel()
119126 toks, ok := tokenise(context.Background(), panicLexer{}, "x\n")
120127 assert.False(t, ok)
121128 assert.Nil(t, toks)
122129 }
123130
124131 func TestSniffImage(t *testing.T) {
132+ t.Parallel()
125133 assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")))
126134 assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a....")))
127135 assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF")))
@@ -132,12 +140,13 @@ func TestSniffImage(t *testing.T) {
132140 }
133141
134142 func TestLocate(t *testing.T) {
135- b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
143+ t.Parallel()
144+ b, tag := domain.RefKindBranch, domain.RefKindTag
136145 d := &repoData{
137- branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")},
138- tags: []*pb.Ref{ref(tag, "v1/rc")},
146+ branches: []domain.Ref{ref(b, "main"), ref(b, "feature/x")},
147+ tags: []domain.Ref{ref(tag, "v1/rc")},
139148 }
140- d.head = d.branches[0]
149+ d.head = &d.branches[0]
141150 for _, tt := range []struct {
142151 in, name, path string
143152 detached, ok bool
@@ -169,6 +178,7 @@ func TestLocate(t *testing.T) {
169178 }
170179
171180 func TestFileCrumbs(t *testing.T) {
181+ t.Parallel()
172182 cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})
173183 require.Len(t, cs, 5)
174184 assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href)
@@ -218,3 +228,38 @@ func normSpace(s string) string {
218228 }
219229 return strings.Join(out, "\n")
220230 }
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 (
99 "strings"
1010 "time"
1111
12- "connectrpc.com/connect"
1312 "github.com/a-h/templ"
1413
15- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/ports"
1615 )
1716
1817 // maxRepos bounds the repositories one page lists.
1918 const maxRepos = 1000
2019
21-// pageSize is the page size asked from the list RPCs: the largest the API
22-// accepts.
20+// pageSize is the page size asked from the commit lists: the largest the use
21+// case accepts for them.
2322 const pageSize = 100
2423
2524 // listRepos reads the repositories the visitor may see, ordered by name.
26-func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) {
27- var repos []*pb.Repo
25+// Every page of ListRepos scans the whole repository list and reads the HEAD
26+// of each repository of the page, so the list is asked in one page of up to
27+// maxRepos: one scan in the common case, whatever the cap of the use case is
28+// (a lower one only means more pages, followed by their token).
29+func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
30+ var repos []ports.RepoView
2831 token := ""
2932 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)
3134 if err != nil {
3235 return nil, err
3336 }
34- repos = append(repos, res.Msg.GetRepos()...)
35- if token = res.Msg.GetNextPageToken(); token == "" {
37+ for _, repo := range page.Repos {
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 == "" {
3645 break
3746 }
3847 }
@@ -48,17 +57,17 @@ var homeCols = []struct{ key, label, class string }{
4857 }
4958
5059 // sortRepos orders repos by the column key; ties keep the order by name.
51-func sortRepos(repos []*pb.Repo, key string, desc bool) {
52- less := func(a, b *pb.Repo) bool {
60+func sortRepos(repos []ports.RepoView, key string, desc bool) {
61+ less := func(a, b ports.RepoView) bool {
5362 switch key {
5463 case "desc":
55- return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription())
64+ return strings.ToLower(a.Description) < strings.ToLower(b.Description)
5665 case "branch":
57- return a.GetDefaultBranch() < b.GetDefaultBranch()
66+ return a.DefaultBranch < b.DefaultBranch
5867 case "push":
59- return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime())
68+ return a.PushedAt.Before(b.PushedAt)
6069 }
61- return a.GetName() < b.GetName()
70+ return a.Name.String() < b.Name.String()
6271 }
6372 sort.SliceStable(repos, func(i, j int) bool {
6473 if desc {
@@ -114,25 +123,25 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
114123 }
115124
116125 // 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()
118128 row := HomeRow{
119- ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()),
120- Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor,
129+ ID: "repo-" + name, Name: name, Href: repoHref(name),
130+ Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor,
121131 }
122- if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 {
123- t := r.GetPushedAt().AsTime()
124- row.Pushed, row.PushedAt = ago(now, t), rfc3339(t)
132+ if !r.PushedAt.IsZero() {
133+ row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
125134 }
126135 return row
127136 }
128137
129138 // repoCmds are the repositories as targets of the ":" command line, but the
130139 // one being viewed.
131-func repoCmds(repos []*pb.Repo, except string) []CmdItem {
140+func repoCmds(repos []ports.RepoView, except string) []CmdItem {
132141 var out []CmdItem
133142 for _, r := range repos {
134- if r.GetName() != except {
135- out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())})
143+ if name := r.Name.String(); name != except {
144+ out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
136145 }
137146 }
138147 return out
M internal/adapters/web/keys.templ +23 -63
@@ -3,72 +3,32 @@
33 package web
44
55 // Keys is the keyboard map. ermite.js turns keys into k-<name> events on
6-// <body>; this element (hx-on + hx-live helpers) is the only place that
7-// reacts to them.
6+// <body>; this element is the only place that binds them, each to one call of
7+// window.ermite (ermite.js), which does the work.
88 templ Keys() {
99 <div
1010 id="keys"
1111 hidden
12- hx-on="k-j from:body -> (async (d) => {
13- if (q('dialog[open]').count) return;
14- let rows = q('[data-rows] > :not([hidden])').arr();
15- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
16- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
17- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
18- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
19- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
20- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
21- })(1);
22- k-k from:body -> (async (d) => {
23- if (q('dialog[open]').count) return;
24- let rows = q('[data-rows] > :not([hidden])').arr();
25- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
26- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
27- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
28- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
29- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
30- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
31- })(-1);
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));
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- }"
12+ hx-on="k-j from:body -> ermite.move(1);
13+ k-k from:body -> ermite.move(-1);
14+ k-top from:body -> ermite.edge(false);
15+ k-bottom from:body -> ermite.edge(true);
16+ k-open from:body -> ermite.open();
17+ k-esc from:body -> ermite.closeDialogs();
18+ k-help from:body -> ermite.dialog('panel-help');
19+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
20+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
21+ k-filter from:body -> ermite.focusFilter();
22+ k-gh from:body -> ermite.go('.brand');
23+ k-s from:body -> ermite.tab('s');
24+ k-t from:body -> ermite.tab('t');
25+ k-c from:body -> ermite.tab('c');
26+ k-r from:body -> ermite.tab('r');
27+ k-m from:body -> ermite.tab('m');
28+ k-next from:body -> ermite.nextNote();
29+ k-split from:body -> ermite.layout('split');
30+ k-unified from:body -> ermite.layout('unified');
31+ k-collapse from:body -> ermite.collapse();
32+ k-permalink from:body -> ermite.permalink()"
7333 ></div>
7434 }
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) {
1919
2020 // AC4: raw HTML, javascript: links and SVG are neutralised.
2121 func TestMarkdownNeutralisation(t *testing.T) {
22+ t.Parallel()
2223 tests := []struct {
2324 name, src string
2425 absent []string
@@ -59,6 +60,7 @@ func TestMarkdownNeutralisation(t *testing.T) {
5960 }
6061 for _, tt := range tests {
6162 t.Run(tt.name, func(t *testing.T) {
63+ t.Parallel()
6264 out, _ := renderMD(t, tt.src)
6365 for _, s := range tt.absent {
6466 assert.NotContains(t, out, s)
@@ -80,6 +82,7 @@ func TestMarkdownNeutralisation(t *testing.T) {
8082 }
8183
8284 func TestMarkdownLinks(t *testing.T) {
85+ t.Parallel()
8386 tests := []struct{ name, src, href string }{
8487 {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`},
8588 {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`},
@@ -95,6 +98,7 @@ func TestMarkdownLinks(t *testing.T) {
9598 }
9699 for _, tt := range tests {
97100 t.Run(tt.name, func(t *testing.T) {
101+ t.Parallel()
98102 out, _ := renderMD(t, tt.src)
99103 if tt.href == "" {
100104 assert.NotContains(t, out, "<a ")
@@ -106,6 +110,7 @@ func TestMarkdownLinks(t *testing.T) {
106110 }
107111
108112 func TestMarkdownOutline(t *testing.T) {
113+ t.Parallel()
109114 out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n")
110115 assert.Equal(t, []Heading{
111116 {Level: 1, ID: "md-title", Text: "Title"},
@@ -124,6 +129,7 @@ func TestMarkdownOutline(t *testing.T) {
124129 }
125130
126131 func TestMarkdownGFM(t *testing.T) {
132+ t.Parallel()
127133 out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n")
128134 assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style")
129135 assert.NotContains(t, out, "style=")
M internal/adapters/web/mr.go +111 -116
@@ -11,14 +11,14 @@ import (
1111 "strconv"
1212 "strings"
1313
14- "connectrpc.com/connect"
1514 "github.com/a-h/templ"
1615
17- pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
1818 )
1919
2020 // The merge request pages (ADR 0017). They read through MergeRequestService
21-// and GitService as the visitor of the request: anonymous sees the merge
21+// and BrowseService as the visitor of the request: anonymous sees the merge
2222 // requests of public repositories, the signed-in owner those of every
2323 // repository, and only the owner gets the forms (mr_post.go handles them).
2424 //
@@ -44,9 +44,9 @@ import (
4444
4545 const (
4646 // 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.
4848 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.
5050 maxMRPages = 20
5151 // maxOpenPages bounds the pages read for the open merge requests, which
5252 // the first list page shows in full.
@@ -260,39 +260,39 @@ func mrNumber(r *http.Request) (int64, error) {
260260 return n, nil
261261 }
262262
263-func stateName(s pb.MergeRequestState) string {
263+func stateName(s domain.MRState) string {
264264 switch s {
265- case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
265+ case domain.MRStateOpen:
266266 return "open"
267- case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
267+ case domain.MRStateMerged:
268268 return "merged"
269- case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
269+ case domain.MRStateClosed:
270270 return "closed"
271271 }
272272 return "unknown"
273273 }
274274
275-func modeName(m pb.MergeMode) string {
275+func modeName(m domain.MergeMode) string {
276276 switch m {
277- case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
277+ case domain.MergeModeFastForward:
278278 return "fast-forward"
279- case pb.MergeMode_MERGE_MODE_SQUASH:
279+ case domain.MergeModeSquash:
280280 return "squash"
281281 }
282282 return ""
283283 }
284284
285285 // 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 {
287287 row := MRRow{
288- Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()),
289- Title: visibleText(m.GetTitle()), State: stateName(m.GetState()),
290- Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()),
291- Author: visibleText(m.GetAuthor()),
288+ Number: "!" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
289+ Title: visibleText(m.Title.String()), State: stateName(m.State),
290+ Branches: visibleText(m.Source) + " → " + visibleText(m.Target),
291+ Author: visibleText(m.Author),
292292 }
293- ts := m.GetUpdatedAt()
294- if !ts.IsValid() {
295- ts = m.GetCreatedAt()
293+ ts := m.UpdatedAt
294+ if ts.IsZero() {
295+ ts = m.CreatedAt
296296 }
297297 row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
298298 return row
@@ -300,30 +300,30 @@ func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow {
300300
301301 // mrList lists the merge requests: the open ones, then the merged and closed
302302 // 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.
304304 func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
305305 d, err := s.loadRepo(ctx, r.PathValue("repo"))
306306 if err != nil {
307307 return nil, 0, err
308308 }
309309 token := r.URL.Query().Get("page")
310- p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())}
310+ p := MRListPage{Default: visibleText(d.repo.DefaultBranch)}
311311 p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
312312 base := mrBase(d.name())
313313
314314 if token == "" {
315315 tok := ""
316316 for i := 0; i < maxOpenPages; i++ {
317- res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
318- Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok,
319- }))
317+ res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
318+ State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok,
319+ })
320320 if err != nil {
321321 return nil, 0, err
322322 }
323- for _, m := range res.Msg.GetMergeRequests() {
323+ for _, m := range res.MergeRequests {
324324 p.Open = append(p.Open, s.mrRow(d.name(), m))
325325 }
326- if tok = res.Msg.GetNextPageToken(); tok == "" {
326+ if tok = res.NextPageToken; tok == "" {
327327 break
328328 }
329329 }
@@ -334,18 +334,18 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component,
334334
335335 tok := token
336336 for i := 0; i < maxMRPages; i++ {
337- res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
338- Repo: d.name(), PageSize: pageSize, PageToken: tok,
339- }))
337+ res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
338+ PageSize: pageSize, PageToken: tok,
339+ })
340340 if err != nil {
341341 return nil, 0, err
342342 }
343- for _, m := range res.Msg.GetMergeRequests() {
344- if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
343+ for _, m := range res.MergeRequests {
344+ if m.State != domain.MRStateOpen {
345345 p.Done = append(p.Done, s.mrRow(d.name(), m))
346346 }
347347 }
348- tok = res.Msg.GetNextPageToken()
348+ tok = res.NextPageToken
349349 if tok == "" || len(p.Done) >= mrPerPage {
350350 break
351351 }
@@ -363,10 +363,10 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component,
363363 p.Done[0].Cursor = true
364364 }
365365 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 {
367367 continue
368368 }
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)})
370370 }
371371
372372 l := s.repoLayout(ctx, d, "merge-requests")
@@ -391,6 +391,7 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
391391 return s.mrPage(ctx, r, true)
392392 }
393393
394+//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
394395 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
395396 n, err := mrNumber(r)
396397 if err != nil {
@@ -404,79 +405,72 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
404405 if err != nil {
405406 return nil, 0, err
406407 }
407- res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
408- Repo: d.name(), Number: n, SinceLastRead: since,
409- }))
408+ res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since)
410409 if err != nil {
411410 return nil, 0, err
412411 }
413- m := res.Msg.GetMergeRequest()
414- if m == nil {
415- return nil, 0, errNotFound
416- }
412+ m := res.MergeRequest
417413 now := s.cfg.Now()
418414 signed := SignedIn(ctx)
419- open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
420- head := m.GetHeadId()
415+ open := m.State == domain.MRStateOpen
416+ head := m.HeadID.String()
421417 self := mrHref(d.name(), n)
422418 if since {
423419 self = mrHref(d.name(), n, "since")
424420 }
425421
426422 p := MRPage{
427- Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
428- State: stateName(m.GetState()), Open: open, Signed: signed,
429- Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
430- Author: visibleText(m.GetAuthor()),
431- Desc: plainParts(m.GetDescription()),
423+ Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
424+ State: stateName(m.State), Open: open, Signed: signed,
425+ Source: visibleText(m.Source), Target: visibleText(m.Target),
426+ Author: visibleText(m.Author),
427+ Desc: plainParts(m.Description.String()),
432428 Head: head, HeadShort: short(head),
433- NFiles: int(res.Msg.GetStats().GetFilesChanged()),
434- Adds: int(res.Msg.GetStats().GetAdditions()),
435- Dels: int(res.Msg.GetStats().GetDeletions()),
436- Files: diffFiles(res.Msg.GetDiff().GetFiles()),
429+ NFiles: res.Stats.FilesChanged,
430+ Adds: res.Stats.Additions,
431+ Dels: res.Stats.Deletions,
432+ Files: diffFiles(res.Diff.Files),
437433 }
438434 p.Opened.Name = p.Author
439- p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
440- if t := m.GetCreatedAt(); t.IsValid() {
441- p.Opened.Abs = absolute(t.AsTime())
435+ p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
436+ if !m.CreatedAt.IsZero() {
437+ p.Opened.Abs = absolute(m.CreatedAt)
442438 }
443- p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
439+ p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
444440 for _, b := range d.branches {
445- switch b.GetShortName() {
446- case m.GetSourceBranch():
447- p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
448- case m.GetTargetBranch():
449- p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
441+ switch b.ShortName {
442+ case m.Source:
443+ p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
444+ case m.Target:
445+ p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
450446 }
451447 }
452- if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
453- p.MergedShort = short(m.GetMergedCommitId())
454- p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
455- p.MergeMode = modeName(m.GetMergeMode())
448+ if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
449+ p.MergedShort = short(m.MergedCommitID.String())
450+ p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
451+ p.MergeMode = modeName(m.MergeMode)
456452 }
457- if st := d.repo.GetSettings(); st != nil {
458- for _, mode := range st.GetAllowedMergeModes() {
459- if name := modeName(mode); name != "" {
460- p.Modes = append(p.Modes, name)
461- }
453+ for _, mode := range d.repo.Settings.AllowedMergeModes {
454+ if name := modeName(mode); name != "" {
455+ p.Modes = append(p.Modes, name)
462456 }
463- p.ChecksBlock = st.GetFailingChecksBlock()
464457 }
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)
466460
467461 // The commits of the request, newest first.
468- for _, c := range res.Msg.GetCommits() {
462+ for _, c := range res.Commits {
469463 row := commitRow(d.name(), c, now)
470464 p.Commits = append(p.Commits, MRCommit{
471465 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
472466 })
473467 }
474- lastRead := res.Msg.GetLastReadId()
468+ lastRead := res.LastReadID.String()
475469 if signed && lastRead != "" {
476470 // The commits are newest first: those before the last read head are new.
477471 // A head that is not among them (the branch was rewritten) marks none.
478- for i, c := range res.Msg.GetCommits() {
479- if c.GetId() != lastRead {
472+ for i, c := range res.Commits {
473+ if c.ID.String() != lastRead {
480474 continue
481475 }
482476 for j := 0; j < i; j++ {
@@ -484,7 +478,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
484478 }
485479 }
486480 }
487- if res.Msg.GetCommitsTruncated() {
481+ if res.CommitsTruncated {
488482 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
489483 }
490484
@@ -502,7 +496,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
502496 switch {
503497 case lastRead == "":
504498 p.SinceNote = "nothing was marked read yet: this is the whole change."
505- case res.Msg.GetLastReadLost():
499+ case res.LastReadLost:
506500 p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
507501 case len(p.Files) == 0:
508502 p.SinceNote = "nothing changed since " + short(lastRead) + "."
@@ -521,12 +515,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
521515 if canWrite {
522516 on = parseOn(r.URL.Query().Get("on"))
523517 }
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)
525519 lines, invisible := diffLines(p.Files)
526520 p.Invisible = invisible
527521 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
528522 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)
530524 }
531525 if canWrite {
532526 p.GeneralForm = &CommentForm{
@@ -537,8 +531,8 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
537531 l := s.repoLayout(ctx, d, "merge-requests")
538532 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
539533 if head != "" {
540- pinRef(&l, target{rev: head, name: m.GetSourceBranch()})
541- if !d.hasBranch(m.GetSourceBranch()) {
534+ pinRef(&l, target{rev: head, name: m.Source})
535+ if !d.hasBranch(m.Source) {
542536 pinRef(&l, target{rev: head, name: short(head), detached: true})
543537 }
544538 }
@@ -551,7 +545,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
551545 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
552546 }
553547 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))
555549 l.HasRail = true
556550 return MergeRequest(l, p), http.StatusOK, nil
557551 }
@@ -559,7 +553,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
559553 // hasBranch tells whether the repository has the branch.
560554 func (d *repoData) hasBranch(name string) bool {
561555 for _, b := range d.branches {
562- if b.GetShortName() == name {
556+ if b.ShortName == name {
563557 return true
564558 }
565559 }
@@ -572,19 +566,17 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *
572566 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
573567 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
574568 CanMarkRead: head != "" && head != lastRead,
575- CanDeleteBranch: source != d.repo.GetDefaultBranch(),
569+ CanDeleteBranch: source != d.repo.DefaultBranch,
576570 }
577571 ff, sq := false, false
578- if st := d.repo.GetSettings(); st != nil {
579- for _, mode := range st.GetAllowedMergeModes() {
580- switch mode {
581- case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
582- ff = true
583- b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
584- case pb.MergeMode_MERGE_MODE_SQUASH:
585- sq = true
586- b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
587- }
572+ for _, mode := range d.repo.Settings.AllowedMergeModes {
573+ switch mode {
574+ case domain.MergeModeFastForward:
575+ ff = true
576+ b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
577+ case domain.MergeModeSquash:
578+ sq = true
579+ b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
588580 }
589581 }
590582 if head != "" {
@@ -637,14 +629,16 @@ func parseOn(s string) *lineRef {
637629 // the target, so a page that shows another diff (since the last read) lists
638630 // them all below, with their line. A reader who can write gets the line numbers
639631 // 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) {
641635 now := s.cfg.Now()
642636 self := mrHref(repo, n)
643637 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
644638
645639 type lineKey struct {
646640 path string
647- side pb.DiffSide
641+ side domain.DiffSide
648642 line int
649643 }
650644 index := map[lineKey]*DiffLine{}
@@ -657,13 +651,13 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
657651 for li := range f.Hunks[hi].Lines {
658652 ln := &f.Hunks[hi].Lines[li]
659653 if ln.oldN > 0 && ln.Kind != "add" {
660- index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
654+ index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
661655 if linkable {
662656 ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
663657 }
664658 }
665659 if ln.newN > 0 && ln.Kind != "del" {
666- index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
660+ index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
667661 if linkable {
668662 ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
669663 }
@@ -671,7 +665,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
671665 }
672666 }
673667 }
674- find := func(path string, side pb.DiffSide, line int) *DiffLine {
668+ find := func(path string, side domain.DiffSide, line int) *DiffLine {
675669 if !inline {
676670 return nil
677671 }
@@ -679,34 +673,35 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
679673 }
680674
681675 for _, c := range comments {
676+ cid := strconv.FormatInt(c.ID, 10)
682677 v := CommentView{
683- ID: c.GetId(), Author: visibleText(c.GetAuthor()),
684- Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
685- Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
678+ ID: cid, Author: visibleText(c.Author),
679+ Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
680+ Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
686681 }
687- v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
688- if t := c.GetCreatedAt(); t.IsValid() {
689- v.Abs = absolute(t.AsTime())
682+ v.When, v.WhenAt = whenOf(now, c.CreatedAt)
683+ if !c.CreatedAt.IsZero() {
684+ v.Abs = absolute(c.CreatedAt)
690685 }
691686 p.NComments++
692- if !c.GetResolved() {
687+ if !c.Resolved {
693688 p.NUnresolved++
694689 }
695- general := c.GetPath() == ""
690+ general := c.Path == ""
696691 var ln *DiffLine
697692 if !general {
698- ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
693+ ln = find(c.Path, c.Side, c.Line)
699694 if ln == nil {
700695 side := "new"
701- if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
696+ if c.Side == domain.SideOld {
702697 side = "old"
703698 }
704- v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
699+ v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
705700 }
706701 }
707702 if canWrite {
708- v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"}
709- if c.GetResolved() {
703+ v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
704+ if c.Resolved {
710705 v.Resolve.Label = "Unresolve"
711706 }
712707 }
@@ -721,9 +716,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
721716 }
722717
723718 if on != nil {
724- side := pb.DiffSide_DIFF_SIDE_NEW
719+ side := domain.SideNew
725720 if on.side == "old" {
726- side = pb.DiffSide_DIFF_SIDE_OLD
721+ side = domain.SideOld
727722 }
728723 if ln := find(on.path, side, on.line); ln != nil {
729724 ln.Form = &CommentForm{
M internal/adapters/web/mr.templ +4 -5
@@ -9,18 +9,17 @@ import "strings"
99 templ MRList(l Layout, p MRListPage) {
1010 @Shell(l, mrListRail(p)) {
1111 if p.Live {
12- <div id="mr-notice"></div>
12+ @LiveSlot("mr-notice")
1313 }
1414 <div class="filter">
1515 <label for="mr-filter" class="sr">filter</label>
1616 <input
1717 id="mr-filter"
1818 type="search"
19- data-filter
19+ data-filter=".mrs tbody tr"
2020 placeholder="Filter by title, branch, author or state…"
2121 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));
23- let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')"
22+ hx-on:input="ermite.filter(this)"
2423 />
2524 </div>
2625 if p.Newest == "" {
@@ -140,7 +139,7 @@ templ MergeRequest(l Layout, p MRPage) {
140139 }
141140 </dl>
142141 </header>
143- <div id="mr-notice"></div>
142+ @LiveSlot("mr-notice")
144143 if len(p.Desc) > 0 {
145144 <p class="mr-desc">@userText(p.Desc)</p>
146145 }
M internal/adapters/web/mr_post.go +139 -71
@@ -5,24 +5,23 @@ package web
55 import (
66 "context"
77 "errors"
8- "fmt"
98 "net/http"
109 "strconv"
1110 "strings"
1211
13- "connectrpc.com/connect"
1412 "github.com/bnema/zerowrap"
1513
16- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
1716 )
1817
1918 // The writes of the merge request pages. Each is a POST that passed the gate
20-// (an Origin of this site and the owner's session), calls the API as the
19+// (an Origin of this site and the owner's session), calls the use case as the
2120 // owner, and answers 303 to the page that shows the result, with
22-// "Cache-Control: no-store". A request the API refuses is answered with a
23-// state page that says why, in the words of the API for the rules the owner
24-// can fix (a text rule, a moved branch, a full merge request), and without
25-// detail for anything else.
21+// "Cache-Control: no-store". A request the use case refuses is answered with a
22+// state page that says why, in the words of the use case for the rules the
23+// owner can fix (a text rule, a moved branch, a full merge request), and
24+// without detail for anything else.
2625
2726 // maxFormBytes bounds the body of a form: a description of 8,000 characters is
2827 // at most 32,000 bytes.
@@ -44,8 +43,8 @@ func seeOther(w http.ResponseWriter, r *http.Request, to string) {
4443 http.Redirect(w, r, to, http.StatusSeeOther)
4544 }
4645
47-// mrAction wraps a POST handler: it bounds the API calls, reads the form and
48-// maps the error of the API to a page.
46+// mrAction wraps a POST handler: it bounds the use case calls, reads the form
47+// and maps the error of the use case to a page.
4948 func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
5049 return func(w http.ResponseWriter, r *http.Request) {
5150 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
6463
6564 // actionFailed answers a refused action.
6665 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
67- msg := ""
68- var ce *connect.Error
69- if errors.As(err, &ce) {
70- msg = ce.Message()
71- }
72- switch code := connect.CodeOf(err); {
73- case code == connect.CodeNotFound:
66+ // The words of the use case, for the rules the owner can fix.
67+ msg := err.Error()
68+ // The order is that of the API's mapping of the domain errors to codes
69+ // (rpc.codeOf), so that a web action answers what the API would.
70+ switch {
71+ case errors.Is(err, context.Canceled):
72+ // The browser is gone.
73+ case errors.Is(err, domain.ErrNotFound):
7474 w.Header().Set("Cache-Control", "no-store")
7575 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
76- case code == connect.CodeInvalidArgument:
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:
76+ case errors.Is(err, domain.ErrAlreadyExists):
8177 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
82- case code == connect.CodeResourceExhausted:
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:
78+ case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
8579 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:
87- // The browser is gone.
80+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
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)
8886 default:
89- id := requestID()
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))
87+ s.internalFailure(ctx, w, r, err)
9488 }
9589 }
9690
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,
98102 // bounded, and a fallback for an empty one.
99103 func rule(msg, fallback string) string {
100104 msg = strings.TrimSpace(visibleText(msg))
@@ -126,69 +130,116 @@ func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string,
126130 repo := r.PathValue("repo")
127131 source := r.PostFormValue("source")
128132 back := repoHref(repo, "compare", source)
129- res, err := s.cfg.MergeRequests.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
130- Repo: repo, Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
131- SourceBranch: source, TargetBranch: r.PostFormValue("target"),
132- }))
133+ name, err := domain.ParseRepoName(repo)
134+ if err != nil {
135+ return "", back, err
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+ })
133141 if err != nil {
134142 return "", back, err
135143 }
136- return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil
144+ return mrHref(repo, mr.Number), back, nil
137145 }
138146
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+
139155 // mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
140156 // one on a line (path, line and side).
141157 func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
142158 repo := r.PathValue("repo")
143159 n, err := number(r)
144160 if err != nil {
145- return "", "", connect.NewError(connect.CodeNotFound, err)
161+ return "", "", err
146162 }
147163 back := mrHref(repo, n)
148- req := &pb.AddCommentRequest{Repo: repo, Number: n, Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
149- if req.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
164+ params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
165+ if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
150166 line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
151167 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")
153169 }
154- req.Line = int32(line)
170+ params.Line = int(line)
155171 switch r.PostFormValue("side") {
156172 case "new":
157- req.Side = pb.DiffSide_DIFF_SIDE_NEW
173+ params.Side = domain.SideNew
158174 case "old":
159- req.Side = pb.DiffSide_DIFF_SIDE_OLD
175+ params.Side = domain.SideOld
160176 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")
162178 }
163179 }
164- res, err := s.cfg.MergeRequests.AddComment(ctx, connect.NewRequest(req))
180+ name, err := domain.ParseRepoName(repo)
165181 if err != nil {
166182 return "", back, err
167183 }
168- return back + "#c" + res.Msg.GetComment().GetId(), back, nil
184+ c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
185+ if err != nil {
186+ return "", back, err
187+ }
188+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
169189 }
170190
171191 // mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
172192 //
173-//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into an API call; the state of the comment is decided by the API
193+//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into a use case call; the state of the comment is decided by the use case
174194 func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
175195 repo := r.PathValue("repo")
176196 n, err := number(r)
177197 if err != nil {
178- return "", "", connect.NewError(connect.CodeNotFound, err)
198+ return "", "", err
179199 }
180200 back := mrHref(repo, n)
181201 resolved, err := formBool(r.PostFormValue("resolved"))
182202 if err != nil {
183- return "", back, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("resolved: %w", err))
203+ return "", back, formError("resolved: " + err.Error())
184204 }
185- res, err := s.cfg.MergeRequests.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{
186- Repo: repo, Number: n, CommentId: r.PathValue("id"), Resolved: resolved,
187- }))
205+ name, err := domain.ParseRepoName(repo)
188206 if err != nil {
189207 return "", back, err
190208 }
191- return back + "#c" + res.Msg.GetComment().GetId(), back, nil
209+ // An id this server never issued is 0: the use case answers not found,
210+ // after the repository and the scope were checked, as for any unknown
211+ // comment.
212+ c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
213+ if err != nil {
214+ return "", back, err
215+ }
216+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
217+}
218+
219+// commentID parses the id of a comment in a path: a positive decimal number,
220+// written without sign or leading zeros. Anything else is 0, which names no
221+// comment.
222+func commentID(s string) int64 {
223+ n, err := strconv.ParseInt(s, 10, 64)
224+ if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
225+ return 0
226+ }
227+ return n
228+}
229+
230+// headID parses a commit id of a form; the empty string is the zero id.
231+//
232+//hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here
233+func headID(s string) (domain.ObjectID, error) {
234+ if s == "" {
235+ return domain.ObjectID{}, nil
236+ }
237+ var v domain.Violations
238+ id, err := domain.ParseObjectID(s)
239+ if err != nil {
240+ v.Add("head_id", "is not a full commit id")
241+ }
242+ return id, v.Err()
192243 }
193244
194245 // mrClose is POST /<repo>/merge-requests/<n>/close.
@@ -196,10 +247,14 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string,
196247 repo := r.PathValue("repo")
197248 n, err := number(r)
198249 if err != nil {
199- return "", "", connect.NewError(connect.CodeNotFound, err)
250+ return "", "", err
200251 }
201252 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 {
203258 return "", back, err
204259 }
205260 return back, back, nil
@@ -213,22 +268,29 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
213268 repo := r.PathValue("repo")
214269 n, err := number(r)
215270 if err != nil {
216- return "", "", connect.NewError(connect.CodeNotFound, err)
271+ return "", "", err
217272 }
218273 back := mrHref(repo, n)
219- var mode pb.MergeMode
274+ var mode domain.MergeMode
220275 switch r.PostFormValue("mode") {
221276 case "fast-forward":
222- mode = pb.MergeMode_MERGE_MODE_FAST_FORWARD
277+ mode = domain.MergeModeFastForward
223278 case "squash":
224- mode = pb.MergeMode_MERGE_MODE_SQUASH
279+ mode = domain.MergeModeSquash
225280 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")
227282 }
228- if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
229- Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"),
230- DeleteSourceBranch: r.PostFormValue("delete_branch") == "1",
231- })); err != nil {
283+ name, err := domain.ParseRepoName(repo)
284+ if err != nil {
285+ return "", back, err
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 {
232294 return "", back, err
233295 }
234296 return back, back, nil
@@ -240,12 +302,18 @@ func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, e
240302 repo := r.PathValue("repo")
241303 n, err := number(r)
242304 if err != nil {
243- return "", "", connect.NewError(connect.CodeNotFound, err)
305+ return "", "", err
244306 }
245307 back := mrHref(repo, n)
246- if _, err := s.cfg.MergeRequests.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{
247- Repo: repo, Number: n, HeadId: r.PostFormValue("head_id"),
248- })); err != nil {
308+ name, err := domain.ParseRepoName(repo)
309+ if 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 {
249317 return "", back, err
250318 }
251319 return back, back, nil
@@ -263,7 +331,7 @@ func formBool(s string) (bool, error) {
263331 }
264332
265333 // normalizeNewlines turns the CRLF of a browser textarea into LF, the line
266-// break the API stores.
334+// break the use case stores.
267335 func normalizeNewlines(s string) string {
268336 return strings.ReplaceAll(s, "\r\n", "\n")
269337 }
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) {
3737 type="search"
3838 placeholder="Switch branch or tag…"
3939 autocomplete="off"
40- data-pick
41- hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"
40+ data-pick="#panel-refs li"
41+ hx-on:input="ermite.filter(this)"
4242 />
4343 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
4444 </div>
@@ -96,14 +96,8 @@ templ OverlayCmd(items []CmdItem) {
9696 autocomplete="off"
9797 spellcheck="false"
9898 aria-controls="cmd-list"
99- hx-on:input="let t = this.value.toLowerCase().trim();
100- q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
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() }"
99+ hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
100+ hx-on:keydown="ermite.cmdKey(event)"
107101 />
108102 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
109103 </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) {
1010 <input
1111 id="home-filter"
1212 type="search"
13- data-filter
13+ data-filter="#repo-rows > tr"
1414 placeholder="Filter repositories…"
1515 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)"
1717 />
1818 </div>
1919 <table class="list home" aria-label="Repositories">
@@ -94,11 +94,10 @@ templ Refs(l Layout, p RefsPage) {
9494 <input
9595 id="ref-filter"
9696 type="search"
97- data-filter
97+ data-filter="#ref-rows > tr"
9898 placeholder="Filter branches and tags…"
9999 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));
101- let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"
100+ hx-on:input="ermite.filter(this)"
102101 />
103102 </div>
104103 <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 {
3131 }
3232
3333 func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
34+ t.Parallel()
3435 c := newPathCache(100, 1)
3536 blob := func(n int) []byte { return make([]byte, n) }
3637 c.store("a", blob(25))
@@ -57,6 +58,7 @@ func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
5758 // A list over a quarter of the budget is not kept, since it would push out the
5859 // others; one at the limit is.
5960 func TestPathCacheSkipsOversizedEntries(t *testing.T) {
61+ t.Parallel()
6062 c := newPathCache(100, 1)
6163 var builds atomic.Int32
6264 big := func(n int) func() ([]byte, error) {
@@ -80,6 +82,7 @@ func TestPathCacheSkipsOversizedEntries(t *testing.T) {
8082 }
8183
8284 func TestPathCacheConcurrent(t *testing.T) {
85+ t.Parallel()
8386 c := newPathCache(1000, 1)
8487 var wg sync.WaitGroup
8588 for g := range 8 {
@@ -122,6 +125,7 @@ func building(c *pathCache, n int) bool {
122125 // Concurrent misses for one commit build once; a failure is shared and not
123126 // stored; a waiter that gives up leaves the build running.
124127 func TestPathCacheLoadBuildsOnce(t *testing.T) {
128+ t.Parallel()
125129 c := newPathCache(1000, 4)
126130 var builds atomic.Int32
127131 release := make(chan struct{})
@@ -172,6 +176,7 @@ func TestPathCacheLoadBuildsOnce(t *testing.T) {
172176 // commit waits for the running build, and leaves with its ctx if it is
173177 // cancelled first. The slot is the build's, not the caller's.
174178 func TestPathCacheLoadBoundsBuilds(t *testing.T) {
179+ t.Parallel()
175180 c := newPathCache(1000, 1)
176181 started, release := make(chan struct{}), make(chan struct{})
177182 first := make(chan error, 1)
@@ -218,6 +223,7 @@ func TestPathCacheLoadBoundsBuilds(t *testing.T) {
218223 // The slot of a build is kept after its callers leave, and given back when it
219224 // ends, whether it fails or panics.
220225 func TestPathCacheSlotOutlivesCallers(t *testing.T) {
226+ t.Parallel()
221227 c := newPathCache(1000, 1)
222228 started, release := make(chan struct{}), make(chan struct{})
223229 ctx, cancel := context.WithCancel(context.Background())
@@ -240,6 +246,7 @@ func TestPathCacheSlotOutlivesCallers(t *testing.T) {
240246 // A build that panics is handed to its waiters as an error, leaves nothing
241247 // in flight or cached, and does not take the process down.
242248 func TestPathCacheLoadPanic(t *testing.T) {
249+ t.Parallel()
243250 c := newPathCache(1000, 4)
244251 started, release := make(chan struct{}), make(chan struct{})
245252 var once sync.Once
@@ -272,6 +279,7 @@ func TestPathCacheLoadPanic(t *testing.T) {
272279 // The caller that starts a build is released with its context too, and the
273280 // build goes on for the others.
274281 func TestPathCacheLoadStarterLeaves(t *testing.T) {
282+ t.Parallel()
275283 c := newPathCache(1000, 4)
276284 release := make(chan struct{})
277285 build := func() ([]byte, error) { <-release; return []byte("list"), nil }
@@ -294,6 +302,7 @@ func TestPathCacheLoadStarterLeaves(t *testing.T) {
294302 // A failure belongs to the repository that built it: another repository
295303 // asking for the same commit builds its own.
296304 func TestPathCacheLoadFailureStaysInRepo(t *testing.T) {
305+ t.Parallel()
297306 c := newPathCache(1000, 4)
298307 started, release := make(chan struct{}), make(chan struct{})
299308 boom := errors.New("boom")
M internal/adapters/web/paths.go +19 -44
@@ -11,11 +11,8 @@ import (
1111 "io"
1212 "net/http"
1313 "strconv"
14- "strings"
1514
16- "connectrpc.com/connect"
17-
18- pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
1916 )
2017
2118 // errBuildPanicked is what the waiters of a path list get when its build
@@ -31,34 +28,41 @@ type pathList struct {
3128 // paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for
3229 // the file finder (files.js). The commit must be a full id; the answer never
3330 // changes for it, but who may read it does, so every request is authorized
34-// first (ResolveRevision runs the access rule) and only then served from the
31+// first (ResolveRevision runs the access rule, as the caller of the request) and only then served from the
3532 // cache of lists. Caching follows raw: a visitor revalidates every time, with
3633 // an ETag that makes it a 304 (answered before the list is loaded, so it never
3734 // waits for a build); the owner's copy is private and never stored. The ETag
3835 // 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
3938 func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
4039 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
4140 defer cancel()
42- repo, sha := r.PathValue("repo"), r.PathValue("sha")
41+ sha := r.PathValue("sha")
4342 if !isFullID(sha) {
4443 s.fail(ctx, w, r, errNotFound)
4544 return
4645 }
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)
4852 if err != nil {
4953 s.fail(ctx, w, r, err)
5054 return
5155 }
5256 // A ref can be named like a commit id: only the commit itself is served,
5357 // since the cache is keyed by it.
54- if res.Msg.GetCommitId() != sha {
58+ if res.CommitID.String() != sha {
5559 s.fail(ctx, w, r, errNotFound)
5660 return
5761 }
5862
5963 // The validator names the variant that would be served: the gzip bytes
6064 // and the plain JSON are different representations.
61- gz := acceptsGzip(r.Header.Get("Accept-Encoding"))
65+ gz := acceptsGzip(r.Header)
6266 etag := `"paths-` + sha + `"`
6367 if gz {
6468 etag = `"paths-` + sha + `-gz"`
@@ -77,7 +81,7 @@ func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
7781 w.WriteHeader(http.StatusNotModified)
7882 return
7983 }
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) })
8185 if err != nil {
8286 s.fail(ctx, w, r, err)
8387 return
@@ -122,17 +126,17 @@ func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool)
122126 // buildPaths lists the paths of a commit and returns them as gzipped JSON.
123127 // The build is shared by the requests that wait for it, so it does not stop
124128 // 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) {
126130 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout)
127131 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)
129133 if err != nil {
130134 return nil, err
131135 }
132- if res.Msg.GetCommitId() != sha {
136+ if list.CommitID.String() != sha {
133137 return nil, errNotFound
134138 }
135- paths := res.Msg.GetPaths()
139+ paths := list.Paths
136140 if paths == nil {
137141 paths = []string{}
138142 }
@@ -143,7 +147,7 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro
143147 }
144148 enc := json.NewEncoder(zw)
145149 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 {
147151 return nil, err
148152 }
149153 if err := zw.Close(); err != nil {
@@ -156,32 +160,3 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro
156160 copy(body, buf.Bytes())
157161 return body, nil
158162 }
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 (
66 "bytes"
77 "compress/gzip"
88 "context"
9+ "errors"
910 "io"
1011 "net/http"
1112 "net/http/httptest"
1213 "strings"
13- "sync/atomic"
1414 "testing"
1515
16- "connectrpc.com/connect"
1716 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
1818 "github.com/stretchr/testify/require"
1919
20- pb "bnema.dev/ermite-api/gen/ermite"
21- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
2223 )
2324
24-// pathsGit is a Git client that resolves any revision to itself and counts
25-// the lists it builds.
26-type pathsGit struct {
27- ermiteconnect.GitServiceClient
28- lists atomic.Int32
29- paths []string // the list; a.go and b/c.go when nil
30-}
31-
32-func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
33- return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: req.Msg.GetRevision()}), nil
25+// pathsSHA is the commit the path list tests ask for.
26+var pathsSHA = strings.Repeat("a", 40)
27+
28+// pathsBrowse resolves any full id to itself and lists a.go and b/c.go (or
29+// paths) for the commit, as the anonymous visitor. lists is how many times
30+// the list may be built: 0 means ListPaths must not be called.
31+func pathsBrowse(t testing.TB, lists int, paths []string) *mocks.BrowseService {
32+ t.Helper()
33+ browse := mocks.NewBrowseService(t)
34+ browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn(
35+ func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
36+ return domain.ResolvedRevision{CommitID: objectID(t, rev.String())}, nil
37+ }).Maybe()
38+ if lists > 0 {
39+ if paths == nil {
40+ paths = []string{"a.go", "b/c.go"}
41+ }
42+ browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(
43+ domain.PathList{CommitID: objectID(t, pathsSHA), Paths: paths}, nil).Times(lists)
44+ }
45+ return browse
3446 }
3547
36-func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) {
37- g.lists.Add(1)
38- paths := g.paths
39- if paths == nil {
40- paths = []string{"a.go", "b/c.go"}
48+func getPaths(srv *Server, sha string, hdr map[string]string) *httptest.ResponseRecorder {
49+ req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil)
50+ for k, v := range hdr {
51+ req.Header.Set(k, v)
4152 }
42- return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: paths}), nil
53+ rec := httptest.NewRecorder()
54+ srv.ServeHTTP(rec, req)
55+ return rec
4356 }
4457
4558 // 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.
4761 func TestPathsRevalidationDoesNotBuild(t *testing.T) {
48- git := &pathsGit{}
49- srv, err := New(Config{
50- Repos: failing{}, Git: git,
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 }{
62+ t.Parallel()
63+ gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"`
64+ tests := []struct{ name, ae, inm string }{
6765 {"gzip", "gzip", gzTag},
6866 {"identity", "", plainTag},
6967 {"weak", "gzip", "W/" + gzTag},
7068 {"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)
7869 }
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)})
8090
81- // The validator of the other variant does not match.
82- assert.Equal(t, http.StatusOK, get(map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).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")
91+ assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code)
92+ assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"If-None-Match": gzTag}).Code)
8593
86- rec := get(map[string]string{"Accept-Encoding": "gzip"})
94+ rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip"})
8795 assert.Equal(t, gzTag, rec.Header().Get("ETag"))
8896 assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
97+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
8998 zr, err := gzip.NewReader(rec.Body)
9099 require.NoError(t, err)
91100 out, err := io.ReadAll(zr)
92101 require.NoError(t, err)
93102 assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out))
94103
95- rec = get(nil)
104+ rec = getPaths(srv, pathsSHA, nil)
96105 assert.Equal(t, plainTag, rec.Header().Get("ETag"))
97106 assert.Empty(t, rec.Header().Get("Content-Encoding"))
98107 assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String())
99108 }
100109
101-// The cached list holds no spare capacity.
102-func TestBuildPathsCachesATightBody(t *testing.T) {
103- srv, err := New(Config{Repos: failing{}, Git: &pathsGit{}, PublicURL: "https://forge.example"})
104- require.NoError(t, err)
105- body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40))
106- require.NoError(t, err)
107- assert.Equal(t, len(body), cap(body))
110+// The commit is checked as the caller of the request, and only a full id that
111+// resolves to itself is served.
112+func TestPathsRefusals(t *testing.T) {
113+ t.Parallel()
114+ other := strings.Repeat("b", 40)
115+ tests := []struct {
116+ name string
117+ sha string
118+ resolve func() (domain.ResolvedRevision, error)
119+ want int
120+ }{
121+ {"not a full id", "main", nil, http.StatusNotFound},
122+ {"abbreviated id", "aaaaaaa", nil, http.StatusNotFound},
123+ {"commit not found or refused to the caller", pathsSHA, func() (domain.ResolvedRevision, error) { return domain.ResolvedRevision{}, domain.ErrNotFound }, http.StatusNotFound},
124+ {"a ref named like a commit", pathsSHA, func() (domain.ResolvedRevision, error) {
125+ return domain.ResolvedRevision{CommitID: objectID(t, other)}, nil
126+ }, http.StatusNotFound},
127+ {"backend failure", pathsSHA, func() (domain.ResolvedRevision, error) {
128+ return domain.ResolvedRevision{}, errors.New("boom")
129+ }, http.StatusInternalServerError},
130+ }
131+ for _, tt := range tests {
132+ t.Run(tt.name, func(t *testing.T) {
133+ t.Parallel()
134+ browse := mocks.NewBrowseService(t)
135+ if tt.resolve != nil {
136+ browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn(
137+ func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.ResolvedRevision, error) {
138+ return tt.resolve()
139+ })
140+ }
141+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
142+ assert.Equal(t, tt.want, getPaths(srv, tt.sha, nil).Code)
143+ })
144+ }
108145 }
109146
110-func TestAcceptsGzip(t *testing.T) {
111- for _, tt := range []struct {
112- header string
113- want bool
147+// The list is built as the caller of the request: a failure of the use case
148+// is the answer.
149+func TestPathsListFailure(t *testing.T) {
150+ t.Parallel()
151+ tests := []struct {
152+ name string
153+ err error
154+ want int
114155 }{
115- {"", false},
116- {"gzip", true},
117- {"GZip", true},
118- {"deflate, gzip", true},
119- {"deflate, br", false},
120- {"gzip;q=0", false},
121- {"gzip; q=0", false},
122- {"gzip;q=0.0", false},
123- {"gzip ; q = 0", false},
124- {"gzip;q=0.5", true},
125- {"gzip;q=1", true},
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)
156+ {"not found", domain.ErrNotFound, http.StatusNotFound},
157+ {"other", errors.New("boom"), http.StatusInternalServerError},
158+ }
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ t.Parallel()
162+ browse := pathsBrowse(t, 0, nil)
163+ browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(domain.PathList{}, tt.err)
164+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
165+ assert.Equal(t, tt.want, getPaths(srv, pathsSHA, nil).Code)
166+ })
135167 }
136168 }
137169
138-func TestEtagMatches(t *testing.T) {
139- const tag = `"paths-x"`
140- for _, tt := range []struct {
141- header string
142- want bool
170+// The cached list holds no spare capacity; the truncation is reported; a path
171+// is not HTML, so "<", ">" and "&" are not escaped in the JSON.
172+func TestBuildPaths(t *testing.T) {
173+ t.Parallel()
174+ tests := []struct {
175+ name string
176+ paths []string
177+ truncated bool
178+ want string
143179 }{
144- {"", false},
145- {tag, true},
146- {"W/" + tag, true},
147- {`"other"`, false},
148- {`"a", ` + tag, true},
149- {`"a",` + tag + `,"b"`, true},
150- {`"a", W/` + tag, true},
151- {`"a", "b"`, false},
152- {"*", true},
153- {`"a", *`, true},
154- {`"paths-x-gz"`, false},
155- {`w/` + tag, false},
156- } {
157- assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header)
180+ {"plain", []string{"a.go", "b/c.go"}, false, `{"paths":["a.go","b/c.go"],"truncated":false}`},
181+ {"empty", nil, false, `{"paths":[],"truncated":false}`},
182+ {"truncated", []string{"a.go"}, true, `{"paths":["a.go"],"truncated":true}`},
183+ {"html characters", []string{"a&b/<c>.go"}, false, `{"paths":["a&b/<c>.go"],"truncated":false}`},
184+ }
185+ for _, tt := range tests {
186+ t.Run(tt.name, func(t *testing.T) {
187+ t.Parallel()
188+ browse := mocks.NewBrowseService(t)
189+ browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(
190+ domain.PathList{CommitID: objectID(t, pathsSHA), Paths: tt.paths, Truncated: tt.truncated}, nil)
191+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
192+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Anonymous())
193+ body, err := srv.buildPaths(ctx, repoName(t, "r"), pathsSHA)
194+ require.NoError(t, err)
195+ assert.Equal(t, len(body), cap(body))
196+ zr, err := gzip.NewReader(bytes.NewReader(body))
197+ require.NoError(t, err)
198+ out, err := io.ReadAll(zr)
199+ require.NoError(t, err)
200+ assert.Equal(t, tt.want, strings.TrimSpace(string(out)))
201+ })
158202 }
159203 }
160204
161-// A path is not HTML: "<", ">" and "&" are not escaped in the JSON.
162-func TestBuildPathsDoesNotEscapeHTML(t *testing.T) {
163- git := &pathsGit{paths: []string{"a&b/<c>.go"}}
164- srv, err := New(Config{Repos: failing{}, Git: git, PublicURL: "https://forge.example"})
165- require.NoError(t, err)
166- body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40))
167- require.NoError(t, err)
168- zr, err := gzip.NewReader(bytes.NewReader(body))
169- require.NoError(t, err)
170- out, err := io.ReadAll(zr)
171- require.NoError(t, err)
172- assert.Contains(t, string(out), `"a&b/<c>.go"`)
205+// A list that answers for another commit than the one asked is refused.
206+func TestBuildPathsWrongCommit(t *testing.T) {
207+ t.Parallel()
208+ browse := mocks.NewBrowseService(t)
209+ browse.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(
210+ domain.PathList{CommitID: objectID(t, strings.Repeat("b", 40))}, nil)
211+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
212+ _, err := srv.buildPaths(context.Background(), repoName(t, "r"), pathsSHA)
213+ assert.ErrorIs(t, err, domain.ErrNotFound)
173214 }
215+
216+var _ ports.BrowseService = (*mocks.BrowseService)(nil)
M internal/adapters/web/plaintext_test.go +6 -0
@@ -14,6 +14,7 @@ import (
1414 // Plain text stays text; only http(s) addresses become links, without the
1515 // punctuation that ends a sentence; invisible characters are marked.
1616 func TestPlainParts(t *testing.T) {
17+ t.Parallel()
1718 tests := []struct {
1819 name, in string
1920 want []TextPart
@@ -32,6 +33,7 @@ func TestPlainParts(t *testing.T) {
3233 }
3334 for _, tt := range tests {
3435 t.Run(tt.name, func(t *testing.T) {
36+ t.Parallel()
3537 assert.Equal(t, tt.want, plainParts(tt.in))
3638 })
3739 }
@@ -39,12 +41,14 @@ func TestPlainParts(t *testing.T) {
3941
4042 // userText escapes the text and the address, and marks the link as untrusted.
4143 func TestUserTextEscapes(t *testing.T) {
44+ t.Parallel()
4245 var b bytes.Buffer
4346 require.NoError(t, userText(plainParts(`<b>x</b> & http://x.test/?a=1&b=2`)).Render(context.Background(), &b))
4447 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())
4548 }
4649
4750 func TestParseOn(t *testing.T) {
51+ t.Parallel()
4852 assert.Equal(t, &lineRef{side: "new", line: 12, path: "a/b:c.go"}, parseOn("new:12:a/b:c.go"))
4953 assert.Equal(t, &lineRef{side: "old", line: 1, path: "x"}, parseOn("old:1:x"))
5054 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) {
5357 }
5458
5559 func TestShellWord(t *testing.T) {
60+ t.Parallel()
5661 assert.Equal(t, "feat/search", shellWord("feat/search"))
5762 assert.Equal(t, `'a b'`, shellWord("a b"))
5863 assert.Equal(t, `'it'\''s'`, shellWord("it's"))
@@ -61,6 +66,7 @@ func TestShellWord(t *testing.T) {
6166
6267 // The notes of a split row: a context line counts once.
6368 func TestSplitRowNotes(t *testing.T) {
69+ t.Parallel()
6470 a, b := CommentView{ID: "1"}, CommentView{ID: "2"}
6571 ctx := &DiffLine{Notes: []CommentView{a}}
6672 assert.Equal(t, []CommentView{a}, SplitRow{ctx, ctx}.Notes())
M internal/adapters/web/raw.go +47 -24
@@ -4,6 +4,8 @@ package web
44
55 import (
66 "context"
7+ "errors"
8+ "io"
79 "mime"
810 "net/http"
911 "path"
@@ -11,11 +13,16 @@ import (
1113 "time"
1214
1315 "github.com/bnema/zerowrap"
16+
17+ "bnema.dev/ermite/internal/domain"
1418 )
1519
1620 // rawTimeout bounds the stream of one file to the visitor.
1721 const rawTimeout = 5 * time.Minute
1822
23+// rawChunkSize is the size of the writes that stream a file to the visitor.
24+const rawChunkSize = 64 << 10
25+
1926 // rawCSP makes a raw file inert if a browser opens it as a document: it can
2027 // load nothing and run nothing, whatever it contains.
2128 const rawCSP = "default-src 'none'; sandbox"
@@ -24,10 +31,17 @@ const rawCSP = "default-src 'none'; sandbox"
2431 // image types (by their bytes, never SVG) as images, and anything else as an
2532 // attachment of unknown type. A file is never served as HTML or SVG, so what
2633 // 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
2736 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
2837 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
2938 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)
3145 if err != nil {
3246 s.fail(ctx, w, r, err)
3347 return
@@ -44,7 +58,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
4458 h.Set("Content-Type", typ)
4559 h.Set("X-Content-Type-Options", "nosniff")
4660 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))
4862 if attach {
4963 name := path.Base(t.path)
5064 cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
@@ -53,12 +67,15 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
5367 }
5468 h.Set("Content-Disposition", cd)
5569 }
56- if etag := b.info.GetId(); etag != "" {
70+ if etag := b.info.ID.String(); etag != "" {
5771 h.Set("ETag", `"`+etag+`"`)
5872 }
5973 // Revalidated every time, even at a full commit id: a repository can turn
6074 // 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.
6279 cacheHeaders(ctx, h, "no-cache", sessionCache)
6380 if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) {
6481 w.WriteHeader(http.StatusNotModified)
@@ -72,35 +89,41 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
7289 return
7390 }
7491 b.buf = nil
75- for b.stream.Receive() {
76- if _, err := w.Write(b.stream.Msg().GetChunk()); err != nil {
92+ if b.eof {
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) {
77104 return
78105 }
79- }
80- if err := b.stream.Err(); err != nil {
81- // Headers are gone: all that is left is to cut the answer short, so
82- // that the client sees it is incomplete.
83- log := zerowrap.FromCtx(ctx)
84- log.Error().Err(err).Str("repo", repo).Str("path", r.URL.Path).Msg("raw stream failed")
85- panic(http.ErrAbortHandler)
106+ if rerr != nil {
107+ // Headers are gone: all that is left is to cut the answer short,
108+ // so that the client sees it is incomplete.
109+ log := zerowrap.FromCtx(ctx)
110+ log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
111+ panic(http.ErrAbortHandler)
112+ }
86113 }
87114 }
88115
89-// rawOpen finds the file a raw URL designates and opens its stream.
90-func (s *Server) rawOpen(ctx context.Context, r *http.Request) (*blobStream, target, string, error) {
116+// rawTarget finds the file a raw URL designates and the name of its repository.
117+func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, domain.RepoName, error) {
91118 d, err := s.loadRepo(ctx, r.PathValue("repo"))
92119 if err != nil {
93- return nil, target{}, "", err
120+ return target{}, domain.RepoName{}, err
94121 }
95122 t, ok := d.locate(r.PathValue("rest"))
96- if !ok {
97- return nil, target{}, "", errNotFound
98- }
99- b, err := s.openBlob(ctx, d.name(), t)
100- if err != nil {
101- return nil, target{}, "", err
123+ if !ok || t.path == "" {
124+ return target{}, domain.RepoName{}, errNotFound
102125 }
103- return b, t, d.name(), nil
126+ return t, d.repo.Name, nil
104127 }
105128
106129 // 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) {
109132 if img := sniffImage(b.buf); img != "" {
110133 return img, false
111134 }
112- if !b.info.GetBinary() {
135+ if !b.info.Binary {
113136 return "text/plain; charset=utf-8", false
114137 }
115138 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 (
77 "net/url"
88 "strings"
99
10- "connectrpc.com/connect"
11-
12- pb "bnema.dev/ermite-api/gen/ermite"
10+ "bnema.dev/ermite/internal/domain"
1311 )
1412
1513 // resolveRef finds the ref a URL path starts with and the path that follows.
@@ -17,7 +15,9 @@ import (
1715 // that is the short name of a branch or tag wins. A branch wins over a tag of
1816 // the same name. rest has no leading slash and is "" when the path is the ref
1917 // 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) {
2121 names := refsByName(refs)
2222 p = strings.Trim(p, "/")
2323 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) {
2525 return r, strings.TrimPrefix(p[end:], "/"), true
2626 }
2727 }
28- return nil, "", false
28+ return domain.Ref{}, "", false
2929 }
3030
3131 // refsByName indexes the branches and tags by short name. A branch wins over
3232 // a tag of the same name.
33-func refsByName(refs []*pb.Ref) map[string]*pb.Ref {
34- names := make(map[string]*pb.Ref, len(refs))
33+func refsByName(refs []domain.Ref) map[string]domain.Ref {
34+ names := make(map[string]domain.Ref, len(refs))
3535 for _, r := range refs {
36- k := r.GetKind()
37- if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG {
36+ k := r.Kind
37+ if k != domain.RefKindBranch && k != domain.RefKindTag {
3838 continue
3939 }
40- cur, dup := names[r.GetShortName()]
41- if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH {
42- names[r.GetShortName()] = r
40+ cur, dup := names[r.ShortName]
41+ if !dup || cur.Kind != domain.RefKindBranch {
42+ names[r.ShortName] = r
4343 }
4444 }
4545 return names
@@ -71,8 +71,8 @@ func repoHref(repo string, parts ...string) string {
7171 // target is what the URL of a tree, blob, raw or commits page designates: a
7272 // commit, the name it was reached by, and a path in its tree.
7373 type target struct {
74- // rev is what the API is asked for: the id of the commit of a ref, or
75- // the commit id of the URL itself.
74+ // rev is what the use cases are asked for: the id of the commit of a ref,
75+ // or the commit id of the URL itself.
7676 rev string
7777 // name is how the URL spells the ref: a branch or a tag, or the commit id
7878 // when detached.
@@ -97,32 +97,34 @@ func (t target) commitID() string {
9797 // resolveCommit sets the full id of a target named by an abbreviated commit id,
9898 // with one ResolveRevision. The page renders without it (it only lacks the file
9999 // 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) {
101103 if !t.detached || isFullID(t.rev) {
102104 return
103105 }
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)
105107 if err != nil {
106108 return
107109 }
108- t.commit = res.Msg.GetCommitId()
110+ t.commit = res.CommitID.String()
109111 }
110112
111113 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
112114 // longest prefix of the path that is the short name of a branch or a tag
113115 // (names may contain "/"); a branch wins over a tag of the same name. An
114116 // 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.
116118 func (d *repoData) locate(rest string) (target, bool) {
117119 rest = strings.Trim(rest, "/")
118120 if rest == "" {
119121 if d.head == nil {
120122 return target{}, false
121123 }
122- return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
124+ return target{rev: d.head.CommitID.String(), name: d.head.ShortName}, true
123125 }
124- if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" {
125- return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
126+ if ref, p, ok := resolveRef(d.allRefs(), rest); ok && !ref.CommitID.IsZero() {
127+ return target{rev: ref.CommitID.String(), name: ref.ShortName, path: p}, true
126128 }
127129 first, p, _ := strings.Cut(rest, "/")
128130 if isCommitID(first) {
@@ -152,7 +154,7 @@ func (d *repoData) isBranchHead(t target) bool {
152154 return false
153155 }
154156 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 {
156158 return true
157159 }
158160 }
M internal/adapters/web/refs_test.go +29 -12
@@ -3,27 +3,42 @@
33 package web
44
55 import (
6+ "crypto/sha1" // #nosec G505 -- ids for tests
7+ "encoding/hex"
68 "testing"
79
810 "github.com/stretchr/testify/assert"
911
10- pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
1113 )
1214
13-func ref(kind pb.RefKind, short string) *pb.Ref {
14- return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short}
15+// commitOf is a commit id derived from a name, so that two refs of one name
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}
1529 }
1630
1731 // AC3: a ref with slashes is found by the longest matching prefix.
1832 func TestResolveRef(t *testing.T) {
19- b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
20- refs := []*pb.Ref{
33+ t.Parallel()
34+ b, tag := domain.RefKindBranch, domain.RefKindTag
35+ refs := []domain.Ref{
2136 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"),
2338 }
2439 tests := []struct {
2540 path, ref, rest string
26- kind pb.RefKind
41+ kind domain.RefKind
2742 ok bool
2843 }{
2944 {"main", "main", "", b, true},
@@ -33,25 +48,27 @@ func TestResolveRef(t *testing.T) {
3348 {"feature/other", "feature", "other", b, true},
3449 {"/feature/x/", "feature/x", "", b, true},
3550 {"v1/rc/README", "v1/rc", "README", tag, true},
36- {"v1/README", "", "", 0, false},
37- {"nope/main", "", "", 0, false},
38- {"", "", "", 0, false},
51+ {"v1/README", "", "", "", false},
52+ {"nope/main", "", "", "", false},
53+ {"", "", "", "", false},
3954 }
4055 for _, tt := range tests {
4156 t.Run(tt.path, func(t *testing.T) {
57+ t.Parallel()
4258 r, rest, ok := resolveRef(refs, tt.path)
4359 assert.Equal(t, tt.ok, ok)
4460 if !ok {
4561 return
4662 }
47- assert.Equal(t, tt.ref, r.GetShortName())
48- assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name")
63+ assert.Equal(t, tt.ref, r.ShortName)
64+ assert.Equal(t, tt.kind, r.Kind, "a branch wins over a tag of the same name")
4965 assert.Equal(t, tt.rest, rest)
5066 })
5167 }
5268 }
5369
5470 func TestRepoHref(t *testing.T) {
71+ t.Parallel()
5572 assert.Equal(t, "/demo", repoHref("demo"))
5673 assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go"))
5774 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 (
99
1010 "github.com/a-h/templ"
1111
12- pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
1313 )
1414
1515 // 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
3333 // refsPage is the content of the refs page, which the live updates of the
3434 // page render again.
3535 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())
3737
38- def := d.repo.GetDefaultBranch()
38+ def := d.repo.DefaultBranch
3939 p := RefsPage{Default: def}
40- branches := append([]*pb.Ref(nil), d.branches...)
40+ branches := append([]domain.Ref(nil), d.branches...)
4141 sort.SliceStable(branches, func(i, j int) bool {
42- a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def
42+ a, b := branches[i].ShortName == def, branches[j].ShortName == def
4343 return a && !b
4444 })
4545 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))
4747 }
48- tags := append([]*pb.Ref(nil), d.tags...)
48+ tags := append([]domain.Ref(nil), d.tags...)
4949 sort.SliceStable(tags, func(i, j int) bool {
50- return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at
50+ return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at
5151 })
5252 for i, t := range tags {
5353 p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0))
5454 }
5555 if len(tags) > 0 {
56- p.Latest = tags[0].GetShortName()
56+ p.Latest = tags[0].ShortName
5757 }
5858 if d.cut {
5959 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 {
6161 return p
6262 }
6363
64-func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
65- c := commits[r.GetCommitId()]
64+func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
65+ id := r.CommitID.String()
66+ c := commits[id]
6667 row := RefRow{
67- Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()),
68- Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt,
68+ Name: r.ShortName, Href: repoHref(d.name(), "tree", r.ShortName),
69+ Short: short(id), Subject: c.subject, When: c.when, WhenAt: c.whenAt,
6970 Default: isDefault, Cursor: cursor,
7071 }
71- if r.GetCommitId() != "" {
72- row.CHref = repoHref(d.name(), "commit", r.GetCommitId())
72+ if id != "" {
73+ row.CHref = repoHref(d.name(), "commit", id)
7374 }
7475 return row
7576 }
M internal/adapters/web/repo.go +84 -53
@@ -10,10 +10,8 @@ import (
1010 "sync"
1111 "time"
1212
13- "connectrpc.com/connect"
14- "google.golang.org/protobuf/types/known/timestamppb"
15-
16- pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
1715 )
1816
1917 // maxRefs bounds the refs one page reads. A repository with more has its
@@ -23,44 +21,69 @@ const maxRefs = 1000
2321 // repoData is what the pages of one repository start from: its metadata and
2422 // its refs, read once per request.
2523 type repoData struct {
26- repo *pb.Repo
27- branches []*pb.Ref
28- tags []*pb.Ref
24+ repo ports.RepoView
25+ branches []domain.Ref
26+ tags []domain.Ref
2927 // cut is true when the repository has more refs than maxRefs.
3028 cut bool
3129 // head is the branch the repository opens on: its default branch, or the
3230 // first one when the default does not exist. nil for an empty repository.
33- head *pb.Ref
31+ head *domain.Ref
32+}
33+
34+// repoView reads a repository as the caller of the request may see it, with
35+// the Git state of its summary. A missing or private repository is
36+// domain.ErrNotFound.
37+func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) {
38+ repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
39+ if err != nil {
40+ return ports.RepoView{}, err
41+ }
42+ view, err := s.cfg.Repos.View(ctx, repo)
43+ if err != nil {
44+ return ports.RepoView{}, err
45+ }
46+ if view.Settings.IsZero() {
47+ view.Settings = domain.DefaultRepoSettings()
48+ }
49+ return view, nil
3450 }
3551
3652 // loadRepo reads a repository and its refs. A missing, private or invalidly
37-// named repository is a not_found / invalid_argument error of the API, which
38-// the caller turns into the 404 page.
39-func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
40- res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
53+// named repository is a domain.ErrNotFound / invalid name error, which the
54+// caller turns into the 404 page.
55+func (s *Server) loadRepo(ctx context.Context, rawName string) (*repoData, error) {
56+ name, err := domain.ParseRepoName(rawName)
57+ if err != nil {
58+ return nil, err
59+ }
60+ view, err := s.repoView(ctx, name)
4161 if err != nil {
4262 return nil, err
4363 }
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).
4568 token, n := "", 0
4669 for {
47- page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{
48- Repo: name, PageSize: pageSize, PageToken: token,
49- Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG},
50- }))
70+ page, err := s.cfg.Browse.ListRefs(ctx, principal(ctx), name, ports.ListRefsParams{
71+ Kinds: []domain.RefKind{domain.RefKindBranch, domain.RefKindTag},
72+ PageSize: maxRefs, PageToken: token,
73+ })
5174 if err != nil {
5275 return nil, err
5376 }
54- for _, r := range page.Msg.GetRefs() {
55- switch r.GetKind() {
56- case pb.RefKind_REF_KIND_BRANCH:
77+ for _, r := range page.Refs {
78+ switch r.Kind {
79+ case domain.RefKindBranch:
5780 d.branches = append(d.branches, r)
58- case pb.RefKind_REF_KIND_TAG:
81+ case domain.RefKindTag:
5982 d.tags = append(d.tags, r)
6083 }
6184 n++
6285 }
63- if token = page.Msg.GetNextPageToken(); token == "" {
86+ if token = page.NextPageToken; token == "" {
6487 break
6588 }
6689 if n >= maxRefs {
@@ -68,24 +91,24 @@ func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
6891 break
6992 }
7093 }
71- for _, b := range d.branches {
72- if b.GetShortName() == d.repo.GetDefaultBranch() {
73- d.head = b
94+ for i := range d.branches {
95+ if d.branches[i].ShortName == d.repo.DefaultBranch {
96+ d.head = &d.branches[i]
7497 }
7598 }
7699 if d.head == nil && len(d.branches) > 0 {
77- d.head = d.branches[0]
100+ d.head = &d.branches[0]
78101 }
79102 return d, nil
80103 }
81104
82105 // allRefs is the branches then the tags.
83-func (d *repoData) allRefs() []*pb.Ref {
84- return append(append([]*pb.Ref(nil), d.branches...), d.tags...)
106+func (d *repoData) allRefs() []domain.Ref {
107+ return append(append([]domain.Ref(nil), d.branches...), d.tags...)
85108 }
86109
87110 // 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() }
89112
90113 // layout is the Layout of the pages of a repository: the sections, the ref
91114 // 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() }
93116 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
94117 l := s.base()
95118 l.Repo = d.name()
96- def := d.repo.GetDefaultBranch()
119+ def := d.repo.DefaultBranch
97120 if d.head != nil {
98- def = d.head.GetShortName()
121+ def = d.head.ShortName
99122 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())
101124 l.setFinder(l.SHA, def)
102125 }
103126 mk := func(key, label, href, id string) Section {
@@ -120,12 +143,12 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
120143 }
121144 if l.Ref != "" {
122145 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}
124147 l.Heads = append(l.Heads, it)
125148 l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
126149 }
127150 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)}
129152 l.Tags = append(l.Tags, it)
130153 l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
131154 }
@@ -151,7 +174,7 @@ type refCommit struct {
151174
152175 // commitsOf reads the head commit of each ref, a few at a time. A ref whose
153176 // 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 {
155178 const workers = 8
156179 var (
157180 mu sync.Mutex
@@ -161,10 +184,10 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
161184 )
162185 now := s.cfg.Now()
163186 for _, r := range refs {
164- id := r.GetCommitId()
165- if id == "" {
187+ if r.CommitID.IsZero() {
166188 continue
167189 }
190+ id := r.CommitID.String()
168191 mu.Lock()
169192 _, done := out[id]
170193 if !done {
@@ -179,12 +202,12 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
179202 go func() {
180203 defer func() { <-sem; wg.Done() }()
181204 // 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, "")
183206 var rc refCommit
184- if err == nil && len(res.Msg.GetCommits()) == 1 {
185- row := commitRow(repo, res.Msg.GetCommits()[0], now)
207+ if err == nil && len(res.Commits) == 1 {
208+ row := commitRow(repo.String(), res.Commits[0], now)
186209 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)
188211 }
189212 mu.Lock()
190213 out[id] = rc
@@ -196,32 +219,40 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
196219 }
197220
198221 // whenOf is the relative and the machine form of a time; both are "" when
199-// the time is unknown.
200-func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) {
201- if !ts.IsValid() {
222+// the time is unknown (the zero time).
223+func whenOf(now time.Time, t time.Time) (when, at string) {
224+ if t.IsZero() {
202225 return "", ""
203226 }
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()
205236 }
206237
207238 // commitRow is the row every list of commits starts from: the short id, the
208239 // subject, the author and the date of the committer, all fit to display. The
209240 // 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 {
211242 row := CommitRow{
212- Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
213- CHref: repoHref(repo, "commit", c.GetId()),
214- Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
243+ Short: short(c.ID.String()), Subject: visibleText(c.Summary),
244+ CHref: repoHref(repo, "commit", c.ID.String()),
245+ Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1,
215246 }
216- row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime())
247+ row.When, row.WhenAt = whenOf(now, c.Committer.Time)
217248 return row
218249 }
219250
220251 // 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 {
222253 m := map[string][]string{}
223254 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)
225256 }
226257 for _, v := range m {
227258 sort.Strings(v)
@@ -235,7 +266,7 @@ func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage
235266 l.Title = d.name() + " · " + s.instance
236267 l.Sections = nil
237268 l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
238- branch := d.repo.GetDefaultBranch()
269+ branch := d.repo.DefaultBranch
239270 return l, StatePage{
240271 Code: "empty", Title: d.name() + " is empty",
241272 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 (
1616 "github.com/bnema/zerowrap"
1717
1818 "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
1921 )
2022
2123 // The owner's session on the web (ADR 0018).
@@ -29,7 +31,7 @@ import (
2931 // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
3032 // the origin of the public URL, and a valid session, except the login POST,
3133 // 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.
3335
3436 // CookieName is the session cookie. The __Host- prefix makes browsers accept
3537 // it only with Secure, Path=/ and no Domain, and refuse it from a sibling
@@ -39,31 +41,6 @@ const CookieName = "__Host-ermite"
3941 // loginPrefix is the path of login links, up to the nonce.
4042 const loginPrefix = "/-/login/"
4143
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-
6744 type signedInKey struct{}
6845
6946 // 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
150127 varyCookie(h)
151128 return r
152129 }
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
154143 }
155144
156145 // sessionStillValid checks again the cookie a long-lived request started
@@ -275,12 +264,12 @@ func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
275264 nonce := r.PathValue("nonce")
276265 var token string
277266 var lifetime time.Duration
278- err := ErrLinkRejected
267+ err := ports.ErrLinkRejected
279268 if validNonce(nonce) {
280269 token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce)
281270 }
282271 switch {
283- case errors.Is(err, ErrLinkRejected):
272+ case errors.Is(err, ports.ErrLinkRejected):
284273 s.cfg.Limiter.Fail(ctx, addr)
285274 s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
286275 return
M internal/adapters/web/session_test.go +79 -45
@@ -13,30 +13,58 @@ import (
1313 "time"
1414
1515 "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
1617 "github.com/stretchr/testify/require"
1718
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"
1922 )
2023
21-// fakeSessions accepts one token.
22-type fakeSessions struct {
23- valid string
24- err error
25- used []string
24+// nonceA is the one valid login link of the mocks.
25+var nonceA = strings.Repeat("a", 43)
26+
27+// newVerifier is a WebSessionService that accepts the session token valid, checked
28+// by verify when it is not nil, and that does not know any login link. A
29+// call that is not expected fails the test.
30+func newVerifier(t *testing.T, valid string, verify func(token string) (bool, error)) *mocks.WebSessionService {
31+ t.Helper()
32+ if verify == nil {
33+ verify = func(token string) (bool, error) { return token == valid, nil }
34+ }
35+ m := mocks.NewWebSessionService(t)
36+ m.EXPECT().Verify(mock.Anything, mock.Anything).RunAndReturn(
37+ func(_ context.Context, token string) (domain.Principal, bool, error) {
38+ ok, err := verify(token)
39+ if !ok || err != nil {
40+ return domain.Anonymous(), false, err
41+ }
42+ return domain.Owner(), true, nil
43+ }).Maybe()
44+ return m
2645 }
2746
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+}
2953
30-func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) {
31- f.used = append(f.used, n)
32- if n != strings.Repeat("a", 43) {
33- return "", 0, ErrLinkRejected
34- }
35- return f.valid, time.Hour, nil
54+// newSessions is a WebSessionService with one session token and one login link.
55+func newSessions(t *testing.T, valid string) *mocks.WebSessionService {
56+ t.Helper()
57+ m := newVerifier(t, valid, nil)
58+ expectLogin(m, valid)
59+ return m
3660 }
3761
38-func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) {
39- return ctx, f.err == nil && tok == f.valid, f.err
62+// newBrokenSessions is a WebSessionService whose check cannot be made.
63+func newBrokenSessions(t *testing.T, valid string) *mocks.WebSessionService {
64+ t.Helper()
65+ m := newVerifier(t, valid, func(string) (bool, error) { return false, errors.New("kv down") })
66+ expectLogin(m, valid)
67+ return m
4068 }
4169
4270 type countingLimiter struct{ fails int }
@@ -44,19 +72,18 @@ type countingLimiter struct{ fails int }
4472 func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 }
4573 func (l *countingLimiter) Fail(context.Context, string) { l.fails++ }
4674
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) {
4876 t.Helper()
4977 lim := &countingLimiter{}
50- srv, err := New(Config{
51- Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
52- PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1",
53- Sessions: sess, Limiter: lim,
78+ srv := newTestServer(t, Config{
79+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
80+ PublicURL: publicURL, Sessions: sess, Limiter: lim,
5481 })
55- require.NoError(t, err)
5682 return srv, lim
5783 }
5884
5985 func TestOriginOf(t *testing.T) {
86+ t.Parallel()
6087 for in, want := range map[string]string{
6188 "https://forge.example": "https://forge.example",
6289 "https://Forge.Example/": "https://forge.example",
@@ -75,6 +102,7 @@ func TestOriginOf(t *testing.T) {
75102 }
76103
77104 func TestValidNonce(t *testing.T) {
105+ t.Parallel()
78106 assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a"))
79107 for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} {
80108 assert.False(t, validNonce(n), n)
@@ -84,7 +112,8 @@ func TestValidNonce(t *testing.T) {
84112 // The gate: a POST needs the Origin of the public URL, whatever Host says, and
85113 // a session unless it is the login.
86114 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")
88117 do := func(path, origin, cookie string) int {
89118 req := httptest.NewRequest(http.MethodPost, path, nil)
90119 req.Host = "forge.example"
@@ -122,14 +151,14 @@ func TestGate(t *testing.T) {
122151 // one the router matches, and on exactly loginPrefix + nonce. An encoded slash
123152 // must not turn another route into a "login".
124153 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
126156 lim := &countingLimiter{}
127- srv, err := New(Config{
128- Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
129- MergeRequests: ermiteconnect.NewMergeRequestServiceClient(http.DefaultClient, "http://unused.invalid"),
130- PublicURL: "https://forge.example", Sessions: sess, Limiter: lim,
157+ srv := newTestServer(t, Config{
158+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
159+ MergeRequests: mocks.NewMergeRequestService(t),
160+ Sessions: sess, Limiter: lim,
131161 })
132- require.NoError(t, err)
133162 post := func(target, cookie string) int {
134163 req := httptest.NewRequest(http.MethodPost, target, nil)
135164 req.Header.Set("Origin", "https://forge.example")
@@ -140,7 +169,7 @@ func TestGateLoginExemptionIsExact(t *testing.T) {
140169 srv.ServeHTTP(rec, req)
141170 return rec.Code
142171 }
143- nonce := strings.Repeat("a", 43)
172+ nonce := nonceA
144173 for _, target := range []string{
145174 "/-%2Flogin%2Fx/merge-requests/1/close",
146175 "/-%2flogin%2fx/merge-requests/1/close",
@@ -159,25 +188,28 @@ func TestGateLoginExemptionIsExact(t *testing.T) {
159188 } {
160189 assert.Equal(t, http.StatusForbidden, post(target, ""), target)
161190 }
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)
163193
194+ sess.EXPECT().Login(mock.Anything, nonce).Return("tok", time.Hour, nil).Once()
164195 assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in")
165- assert.Equal(t, []string{nonce}, sess.used)
166196 }
167197
168198 // A session check that cannot be made fails closed (anonymous) and keeps the
169199 // cookie; a check that says no clears it.
170200 func TestWithSessionFailsClosed(t *testing.T) {
171- broken := &fakeSessions{valid: "tok", err: errors.New("kv down")}
201+ t.Parallel()
202+ broken := newBrokenSessions(t, "tok")
172203 srv, _ := newSessionServer(t, broken, "https://forge.example")
173204 req := httptest.NewRequest(http.MethodGet, "/", nil)
174205 req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
175206 rec := httptest.NewRecorder()
176207 r := srv.withSession(rec, req)
177208 assert.False(t, SignedIn(r.Context()))
209+ assert.Equal(t, domain.Anonymous(), principal(r.Context()))
178210 assert.Empty(t, rec.Header().Values("Set-Cookie"))
179211
180- srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example")
212+ srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example")
181213 req = httptest.NewRequest(http.MethodGet, "/", nil)
182214 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
183215 rec = httptest.NewRecorder()
@@ -187,14 +219,18 @@ func TestWithSessionFailsClosed(t *testing.T) {
187219
188220 req = httptest.NewRequest(http.MethodGet, "/", nil)
189221 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")
191226 }
192227
193228 // GO-006: the answer to a request with an invalid cookie is not cached and
194229 // varies on Cookie, and nothing later in the request undoes it: the page
195230 // writer, the raw file handler and the event stream keep it.
196231 func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
197- sess := &fakeSessions{valid: "tok"}
232+ t.Parallel()
233+ sess := newSessions(t, "tok")
198234 srv, _ := newSessionServer(t, sess, "https://forge.example")
199235
200236 // withSession alone.
@@ -207,7 +243,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
207243 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
208244
209245 // 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")
211247 rec = httptest.NewRecorder()
212248 broken.withSession(rec, req)
213249 assert.Empty(t, rec.Header().Values("Cache-Control"))
@@ -226,7 +262,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
226262 rec := httptest.NewRecorder()
227263 srv.ServeHTTP(rec, req)
228264 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)
230266 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target)
231267 }
232268
@@ -251,23 +287,21 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
251287
252288 // Without sessions configured there is no login route and no cookie is read.
253289 func TestNoSessionsNoRoutes(t *testing.T) {
254- srv, err := New(Config{
255- Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
256- PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
257- })
258- require.NoError(t, err)
290+ t.Parallel()
291+ srv := newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)})
259292 rec := httptest.NewRecorder()
260293 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil))
261294 assert.Equal(t, 404, rec.Code)
262295
263- _, err = New(Config{
264- Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
265- PublicURL: "https://forge.example", Sessions: &fakeSessions{},
296+ _, err := New(Config{
297+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
298+ PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t),
266299 })
267300 assert.Error(t, err, "sessions need a limiter")
268301 }
269302
270303 func TestLogPathHidesLinks(t *testing.T) {
304+ t.Parallel()
271305 assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
272306 assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
273307 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) {
1616 <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/>
1717 <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/>
1818 <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>
19+ <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'/>
1920 <script src={ l.Assets.HTMX } defer></script>
2021 <script src={ l.Assets.Live } defer></script>
2122 <script src={ l.Assets.SSE } defer></script>
@@ -92,34 +93,33 @@ templ Header(l Layout) {
9293 }
9394 <div class="hdr-acts">
9495 if l.Ref != "" {
95- <button
96- type="button"
96+ <a
9797 class="btn ref"
98- aria-haspopup="dialog"
99- aria-controls="panel-refs"
98+ href={ templ.SafeURL(repoHref(l.Repo, "refs")) }
99+ data-fire="refs"
100100 aria-keyshortcuts="b"
101101 title="switch branch or tag (b)"
102- hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"
103102 >
104103 <span class="ico i-branch" aria-hidden="true"></span>
105104 <span class="ref-name">{ l.Ref }</span>
106105 <span class="sha">{ l.Short }</span>
107- </button>
106+ </a>
108107 }
109108 <button
110109 type="button"
111110 class="btn search"
111+ data-js
112112 aria-haspopup="dialog"
113113 aria-controls="panel-cmd"
114114 aria-keyshortcuts="Control+K :"
115115 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"
117117 >
118118 <span class="ico i-search" aria-hidden="true"></span>
119119 <span class="search-label">Go to…</span>
120120 <kbd aria-hidden="true">ctrl k</kbd>
121121 </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>
123123 if SignedIn(ctx) {
124124 <form class="owner" method="post" action="/-/logout">
125125 <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 @@
7070 }
7171
7272 @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; }
7375 .dim { color: var(--ink-dim); }
7476 .mute { color: var(--ink-mute); }
7577 .nowrap { white-space: nowrap; }
M internal/adapters/web/static/css/20-shell.css +6 -1
@@ -169,7 +169,12 @@
169169 & > :empty { display: none; }
170170 & .hint-live { color: var(--red); }
171171 }
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+ }
173178
174179 @media (max-width: 1080px) {
175180 /* 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 @@
11 @layer components {
2- /* live updates: the banner of new commits, an empty slot takes no room */
3- #commits-head:empty { display: none; }
4- .live-banner {
2+ /* live updates: a slot is a status region that stays in the page; its banner
3+ comes and goes as its content. Empty, it is out of the way but still in the
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) {
58 padding: var(--s3) var(--s4); font-weight: 700;
69 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);
710 }
8- .live-banner a { color: var(--ink-hi); }
11+ .live-slot a { color: var(--ink-hi); }
912 }
M internal/adapters/web/static/js/ermite.js +310 -17
@@ -1,55 +1,322 @@
1-// ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine
2-// ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused.
3-// It only translates keys into DOM events named "k-<name>" on <body>; every reaction
4-// (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live.
1+// ermite.js: the behaviour of the pages that htmx and hx-live do not give.
2+//
3+// 1. Keys. A global key *sequence* state machine ("g" then "h") and the
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.
513 (() => {
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.
6212 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
7213 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' };
9216 let pending = 0;
10- const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
217+ let pendingTimer = 0;
11218 addEventListener('keydown', (e) => {
12219 const typing = e.target.closest('input, textarea, select, [contenteditable]');
13220 // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
14221 // 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'); }
16223 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'); }
18225 if (typing) return;
19226 // An open dialog takes only the keys that move between dialogs: the page
20227 // 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; }
22229 if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
23230 if (pending) {
24231 const name = goto[e.key];
25232 pending = 0;
26- if (name) { e.preventDefault(); fire(name); }
233+ if (name) { e.preventDefault(); ermite.fire(name); }
27234 return;
28235 }
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; }
30237 const name = single[e.key];
31238 if (!name) return;
32- if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault();
33- fire(name);
239+ if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
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);
34252 });
253+ for (const a of $$('a[data-fire="refs"]')) {
254+ a.setAttribute('aria-haspopup', 'dialog');
255+ a.setAttribute('aria-controls', 'panel-refs');
256+ }
257+
35258 // In the app shell the page does not scroll, <main> does: give it focus so arrows,
36259 // PageUp/PageDown and space scroll the content without a click first.
37260 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+ });
38298 })();
39299
40300 // ermiteLive applies the fragments of the event stream (/-/events, see
41301 // internal/adapters/web/events.go). A fragment is a top-level element with an
42302 // id: it replaces the element of the page that has the same id. A fragment
43303 // marked data-gone removes it; a row of a repository the home page does not
44-// list yet is added at the top of the list. A gap, which says the page may
45-// have missed events or that what it shows changed, reloads the page.
304+// list yet is added at the top of the list. A slot (data-slot: the place of a
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.
46310 window.ermiteLive = (() => {
47311 let reloading = false;
312+ let stopped = false;
48313 return {
49314 swap(html) {
50315 const t = document.createElement('template');
51316 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]) {
53320 const old = el.id && document.getElementById(el.id);
54321 if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
55322 if (!old) {
@@ -57,16 +324,42 @@ window.ermiteLive = (() => {
57324 if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
58325 continue;
59326 }
327+ if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; }
60328 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
61- if (old.hidden && old.tagName === 'TR') el.hidden = true;
62329 old.replaceWith(el);
63330 window.htmx?.process(el);
64331 }
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);
65341 },
66342 reload() {
67343 if (reloading) return;
68344 reloading = true;
69345 setTimeout(() => location.reload(), 300);
70346 },
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+ },
71364 };
72365 })();
M internal/adapters/web/static/rules.json +9 -0
@@ -7,6 +7,15 @@
77 { "href_matches": "/*" },
88 { "not": { "href_matches": "/-/*" } },
99 { "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" } },
1019 { "not": { "selector_matches": "[rel~=nofollow]" } }
1120 ]
1221 },
M internal/adapters/web/summary.go +53 -52
@@ -7,15 +7,16 @@ import (
77 "fmt"
88 "html"
99 "html/template"
10+ "io"
1011 "net/http"
1112 "strings"
1213 "time"
1314
14- "connectrpc.com/connect"
1515 "github.com/a-h/templ"
1616 "github.com/bnema/zerowrap"
1717
18- pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
1920 )
2021
2122 // 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,
4041 l, st := s.emptyState(ctx, d)
4142 return State(l, st), http.StatusOK, nil
4243 }
43- ref := d.head.GetShortName()
44+ ref := d.head.ShortName
4445 now := s.cfg.Now()
4546
4647 p := SummaryPage{
@@ -48,16 +49,15 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
4849 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
4950 }
5051
51- commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
52- Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
53- }))
52+ commits, err := s.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "")
5453 if err != nil {
5554 return nil, 0, err
5655 }
5756 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()
5959 row := commitRow(d.name(), c, now)
60- row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
60+ row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0
6161 p.Recent = append(p.Recent, row)
6262 }
6363
@@ -74,7 +74,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
7474 l.Title = d.name() + " · " + s.instance
7575 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
7676 l.HasRail = true
77- l.Lede = d.repo.GetDescription()
77+ l.Lede = d.repo.Description
7878 return Summary(l, p), http.StatusOK, nil
7979 }
8080
@@ -90,7 +90,7 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
9090 }
9191 lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")}
9292 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 != "" {
9494 lt.Name, lt.Href = name, repoHref(d.name(), "tree", name)
9595 lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt
9696 }
@@ -102,33 +102,33 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
102102 func (s *Server) facts(d *repoData, now time.Time) []Fact {
103103 fs := []Fact{
104104 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")},
106106 }
107- if c := d.repo.GetCreatedAt(); c.IsValid() {
108- fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
107+ if c := d.repo.CreatedAt; !c.IsZero() {
108+ fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)})
109109 }
110110 return fs
111111 }
112112
113113 // lastPushFact is the "last push" fact of a repository; the live updates
114114 // render it again.
115-func lastPushFact(r *pb.Repo, now time.Time) Fact {
116- last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
117- if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
118- last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
119- last.Time = rfc3339(t.AsTime())
115+func lastPushFact(r ports.RepoView, now time.Time) Fact {
116+ last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()}
117+ if t := r.PushedAt; !t.IsZero() {
118+ last.Val = ago(now, t) + " · " + absolute(t)
119+ last.Time = rfc3339(t)
120120 }
121121 return last
122122 }
123123
124124 // 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) {
126126 var name string
127127 var best refCommit
128128 for _, t := range tags {
129- c, ok := commits[t.GetCommitId()]
129+ c, ok := commits[t.CommitID.String()]
130130 if ok && c.short != "" && (name == "" || c.at > best.at) {
131- name, best = t.GetShortName(), c
131+ name, best = t.ShortName, c
132132 }
133133 }
134134 return name, best
@@ -142,27 +142,31 @@ type renderedReadme struct {
142142
143143 // readme finds the README at the root of the tree of the head commit, reads
144144 // 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.
146146 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)
148149 if err != nil {
149150 return nil, err
150151 }
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)
152153 }
153154
154155 // readmeIn picks the README among the entries of the directory dir ("" for
155156 // the root) of the commit rev, reads it and renders it. Links in it are
156157 // 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- var best *pb.TreeEntry
158+//
159+//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only
160+func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {
161+ var best *domain.TreeEntry
159162 rank := len(readmeNames)
160- for _, e := range entries {
161- if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
163+ for i := range entries {
164+ e := &entries[i]
165+ if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {
162166 continue
163167 }
164168 for i, n := range readmeNames {
165- if i < rank && strings.EqualFold(e.GetName(), n) {
169+ if i < rank && strings.EqualFold(e.Name, n) {
166170 best, rank = e, i
167171 }
168172 }
@@ -170,54 +174,51 @@ func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entri
170174 if best == nil {
171175 return nil, nil
172176 }
173- rd := &Readme{Name: best.GetName()}
174- if best.GetSize() > maxReadmeBytes {
175- rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
177+ rd := &Readme{Name: best.Name}
178+ if best.Size > maxReadmeBytes {
179+ rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
176180 return &renderedReadme{readme: rd}, nil
177181 }
178- body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes)
182+ body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes)
179183 if err != nil {
180184 return nil, err
181185 }
182186 if binary {
183- rd.Note = best.GetName() + " is not text."
187+ rd.Note = best.Name + " is not text."
184188 return &renderedReadme{readme: rd}, nil
185189 }
186- name := strings.ToLower(best.GetName())
190+ name := strings.ToLower(best.Name)
187191 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)
189193 if err == nil {
190194 rd.HTML = h
191195 return &renderedReadme{readme: rd, outline: outline}, nil
192196 }
193197 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")
195199 }
196200 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
197201 return &renderedReadme{readme: rd}, nil
198202 }
199203
200-// readBlob reads a file through the GetBlob stream, at most limit bytes. It
201-// reports a binary file without its content.
202-func (s *Server) readBlob(ctx context.Context, repo, 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}))
204+// readBlob reads a file, at most limit bytes. It reports a binary file
205+// without its content.
206+func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) {
207+ info, rc, err := s.openBlobAt(ctx, repo, rev, path)
204208 if err != nil {
205209 return nil, false, err
206210 }
207- defer func() { _ = stream.Close() }()
208- for stream.Receive() {
209- m := stream.Msg()
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- }
211+ defer func() { _ = rc.Close() }()
212+ if info.Binary {
213+ return nil, true, nil
217214 }
218- if err := stream.Err(); err != nil {
215+ body, err = io.ReadAll(io.LimitReader(rc, limit+1))
216+ if err != nil {
219217 return nil, false, err
220218 }
219+ if int64(len(body)) > limit {
220+ return body[:limit], false, nil
221+ }
221222 return body, false, nil
222223 }
223224
M internal/adapters/web/text_test.go +1 -0
@@ -10,6 +10,7 @@ import (
1010
1111 // Invisible characters in names and subjects show as markers.
1212 func TestVisibleText(t *testing.T) {
13+ t.Parallel()
1314 assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go"))
1415 assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”"))
1516 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 (
66 "context"
77 "net/http"
88
9- "connectrpc.com/connect"
109 "github.com/a-h/templ"
1110
12- pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
1312 )
1413
1514 // TreeRow is one entry of a directory.
@@ -42,6 +41,8 @@ type TreePage struct {
4241
4342 // tree lists a directory of a ref, with the last commit of each entry, and
4443 // 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
4546 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
4647 d, err := s.loadRepo(ctx, r.PathValue("repo"))
4748 if err != nil {
@@ -51,52 +52,50 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
5152 if !ok {
5253 return nil, 0, errNotFound
5354 }
54- res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{
55- Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true,
56- }))
55+ res, err := s.getTree(ctx, d.repo.Name, t.rev, t.path, true)
5756 if err != nil {
5857 return nil, 0, err
5958 }
60- entries := res.Msg.GetEntries()
59+ entries := res.Entries
6160 now := s.cfg.Now()
6261
6362 rowsOnly := r.URL.Query().Has("rows")
6463 p := TreePage{}
6564 for i, e := range entries {
66- row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0 && !rowsOnly}
67- switch e.GetKind() {
68- case pb.EntryKind_ENTRY_KIND_DIRECTORY:
65+ row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly}
66+ switch e.Kind {
67+ case domain.EntryDirectory:
6968 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)
7170 row.Expand = row.Href + "?rows"
72- case pb.EntryKind_ENTRY_KIND_SYMLINK:
71+ case domain.EntrySymlink:
7372 row.Glyph = "@"
74- row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
75- case pb.EntryKind_ENTRY_KIND_SUBMODULE:
73+ row.Href = repoHref(d.name(), "blob", t.name, e.Path)
74+ case domain.EntrySubmodule:
7675 row.Glyph, row.Size = "+", "-"
7776 default:
78- row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
77+ row.Href = repoHref(d.name(), "blob", t.name, e.Path)
7978 }
80- if c := e.GetLastCommit(); c != nil {
81- last := commitRow(d.name(), c, now)
79+ if c := e.LastCommit; c != nil {
80+ last := commitRow(d.name(), *c, now)
8281 row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt
8382 }
8483 p.Rows = append(p.Rows, row)
8584 }
8685 if rowsOnly {
8786 // The rows alone, for a directory opened in place (files.js).
88- if res.Msg.GetTruncated() {
87+ if res.Truncated {
8988 p.More = repoHref(d.name(), "tree", t.name, t.path)
9089 }
9190 return TreeRows(p), http.StatusOK, nil
9291 }
93- if res.Msg.GetTruncated() {
92+ if res.Truncated {
9493 p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")
9594 }
96- if res.Msg.GetLastCommitsTruncated() {
95+ if res.LastCommitsOmitted {
9796 p.Notes = append(p.Notes, "the last commit is shown for the first entries only.")
9897 }
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)
10099 if err != nil {
101100 return nil, 0, err
102101 }
@@ -107,7 +106,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
107106 l := s.repoLayout(ctx, d, "tree")
108107 // The finder searches the commit this page lists, whichever way the URL
109108 // spelled it (an abbreviated id included): the listing knows it.
110- t.commit = res.Msg.GetCommitId()
109+ t.commit = res.CommitID.String()
111110 pinRef(&l, t)
112111 l.Crumbs = pathCrumbs(d.name(), "tree", t)
113112 if t.path == "" {
M internal/adapters/web/view.go +1 -1
@@ -5,7 +5,7 @@ package web
55 import "html/template"
66
77 // 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.
99
1010 // Crumb is one segment of the path in the header.
1111 type Crumb struct {
M internal/adapters/web/web.go +70 -35
@@ -1,15 +1,16 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
33 // 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.
55 //
6-// The package is a client of the API like any other. It talks to the
7-// generated ermite-api Connect clients, which cmd binds in process to the
8-// handlers of the rpc adapter (ADR 0020), and it knows nothing of the use
9-// cases or the domain. Who the visitor is comes with the request context, set
10-// by the middleware cmd puts in front of the handler: anonymous in P5. The
11-// API then shows public repositories only; a private repository is
12-// indistinguishable from a missing one, here as everywhere.
6+// Like every other inbound adapter, the package reads and writes through the
7+// use case interfaces of package ports (ADR 0024), never through the API. The
8+// caller of every use case call is passed explicitly: the principal of the
9+// request, which is anonymous unless the request carries a valid owner session
10+// (ServeHTTP sets it in the context, principal reads it). The use cases then
11+// show public repositories only to an anonymous caller; a private repository
12+// is indistinguishable from a missing one (domain.ErrNotFound), here as
13+// everywhere.
1314 //
1415 // Adding a page: a handler on Server, a route in routes, a templ page in
1516 // pages.templ and its view model in view.go.
@@ -28,11 +29,11 @@ import (
2829 "strings"
2930 "time"
3031
31- "connectrpc.com/connect"
3232 "github.com/a-h/templ"
3333 "github.com/bnema/zerowrap"
3434
35- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
35+ "bnema.dev/ermite/internal/domain"
36+ "bnema.dev/ermite/internal/ports"
3637 )
3738
3839 // Patterns are the ServeMux patterns the handler serves: everything. The
@@ -41,20 +42,22 @@ import (
4142 // over this one.
4243 var Patterns = []string{"/"}
4344
44-// requestTimeout bounds the API calls that build one page.
45+// requestTimeout bounds the use case calls that build one page.
4546 const requestTimeout = 30 * time.Second
4647
4748 // Config configures a Server.
4849 type Config struct {
49- // Repos and Git are the API clients the pages are built from.
50- Repos ermiteconnect.RepoServiceClient
51- Git ermiteconnect.GitServiceClient
52- // MergeRequests is the client of the merge request pages (ADR 0017).
53- // Without it there are no such pages, nor section for them.
54- MergeRequests ermiteconnect.MergeRequestServiceClient
55- // Events is the client of the event stream behind /-/events. Without
56- // it the pages are not live and the route answers 404.
57- Events ermiteconnect.EventServiceClient
50+ // Repos and Browse are the use cases the pages are built from.
51+ Repos ports.RepoService
52+ Browse ports.BrowseService
53+ // MergeRequests serves the merge request pages (ADR 0017). Without it
54+ // there are no such pages, nor section for them. Leave it unset rather
55+ // than assigning a typed nil pointer.
56+ MergeRequests ports.MergeRequestService
57+ // Events is the source of the event stream behind /-/events. Without
58+ // it the pages are not live and the route answers 404. Leave it unset
59+ // rather than assigning a typed nil pointer.
60+ Events ports.EventService
5861 // Heartbeat is how often an idle event stream gets a comment line;
5962 // DefaultHeartbeat when zero.
6063 Heartbeat time.Duration
@@ -69,10 +72,10 @@ type Config struct {
6972 Now func() time.Time
7073 // Sessions signs the owner in (ADR 0018). Without it there are no login
7174 // routes and every visitor is anonymous.
72- Sessions SessionAuth
75+ Sessions ports.WebSessionService
7376 // Limiter counts the failed logins; required with Sessions. Share it with
7477 // the API and Git, so that a client has one budget of failures.
75- Limiter FailureLimiter
78+ Limiter ports.FailureLimiter
7679 // TrustedProxies are the networks allowed to set X-Forwarded-For, to find
7780 // the address of the client that fails a login.
7881 TrustedProxies []netip.Prefix
@@ -88,12 +91,15 @@ type Server struct {
8891 // pathCache holds the path lists the file finder fetches.
8992 pathCache *pathCache
9093 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
9197 }
9298
9399 // New builds the web handler.
94100 func New(cfg Config) (*Server, error) {
95- if cfg.Repos == nil || cfg.Git == nil {
96- return nil, errors.New("web: the repository and Git API clients are required")
101+ if cfg.Repos == nil || cfg.Browse == nil {
102+ return nil, errors.New("web: the repository and browse use cases are required")
97103 }
98104 u, err := url.Parse(cfg.PublicURL)
99105 if err != nil || u.Host == "" {
@@ -161,6 +167,9 @@ func (s *Server) routes() *http.ServeMux {
161167
162168 // ServeHTTP implements http.Handler.
163169 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()))
164173 r = s.withSession(w, r)
165174 if !s.gate(w, r) {
166175 return
@@ -172,7 +181,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172181 // error, which becomes the 404 or 500 page.
173182 type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
174183
175-// page adapts a handler: it bounds the API calls, renders the component into
184+// page adapts a handler: it bounds the use case calls, renders the component into
176185 // a buffer (so that a failure never leaves half a page), and maps errors to
177186 // the state pages.
178187 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
209218 if h.Get("Cache-Control") == "" {
210219 cacheHeaders(ctx, h, "no-cache", sessionCache)
211220 }
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)))
212247 w.WriteHeader(status)
213248 if r.Method != http.MethodHead {
214- _, _ = w.Write(buf.Bytes())
249+ _, _ = w.Write(body)
215250 }
216251 }
217252
218-// fail answers an error of the API: not found (and a name or revision the
219-// API refuses, which no page can show either) gives the 404 page, a visitor
220-// that left gives nothing, anything else gives the 500 page with an id to
221-// find the cause in the log.
253+// fail answers an error of a use case: not found (and a name or revision the
254+// use case refuses, which no page can show either) gives the 404 page, a
255+// visitor that left gives nothing, anything else gives the 500 page with an id
256+// to find the cause in the log.
222257 func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
223- switch code := connect.CodeOf(err); {
224- case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
258+ switch {
259+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
225260 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):
227262 // The client is gone; there is nobody to answer.
228263 default:
229264 id := requestID()
@@ -234,8 +269,8 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques
234269 }
235270
236271 // 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).
238-var errNotFound = errors.New("not found")
272+// although the use case did not say so (a ref or a path that is not there).
273+var errNotFound = domain.ErrNotFound
239274
240275 // requestID is a short random id, shown on the 500 page and logged.
241276 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
@@ -19,8 +19,8 @@ import (
1919
2020 pb "bnema.dev/ermite-api/gen/ermite"
2121 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22- "bnema.dev/ermite/internal/app"
2322 "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
2424 )
2525
2626 // clients are the generated Connect clients of one caller.
@@ -105,7 +105,7 @@ func codeName(err error) string {
105105 // commit per file to its main branch with git.
106106 func (h *harness) seed(name string, vis domain.Visibility, files ...string) {
107107 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})
109109 require.NoError(h.t, err)
110110 if len(files) == 0 {
111111 return
R cmd/ermite/api_test.go → internal/app/api_test.go +5 -74 renamed, 91% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
@@ -24,9 +24,9 @@ import (
2424
2525 pb "bnema.dev/ermite-api/gen/ermite"
2626 "bnema.dev/ermite/internal/adapters/rpc"
27- "bnema.dev/ermite/internal/app"
2827 "bnema.dev/ermite/internal/clientlimit"
2928 "bnema.dev/ermite/internal/domain"
29+ "bnema.dev/ermite/internal/ports"
3030 )
3131
3232 // principals of the access tables.
@@ -690,8 +690,8 @@ func TestGetBlobStreamsInChunks(t *testing.T) {
690690 }
691691 }
692692
693-func createParams(h *harness, name string, vis domain.Visibility) app.CreateParams {
694- return app.CreateParams{Name: h.name(name), Visibility: vis}
693+func createParams(h *harness, name string, vis domain.Visibility) ports.CreateRepoParams {
694+ return ports.CreateRepoParams{Name: h.name(name), Visibility: vis}
695695 }
696696
697697 // TestUpdateRepoDefaultBranch: HEAD moves with git symbolic-ref once the
@@ -803,77 +803,8 @@ func TestDeleteRepoMovesToTrash(t *testing.T) {
803803 assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process")
804804 }
805805
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-
874806 // A principal in the base context of the HTTP server does not authenticate
875-// requests: only the in-process transport can mark a context as trusted, and
876-// requests from the network are judged by their headers.
807+// requests: requests from the network are judged by their headers.
877808 func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) {
878809 h := newHarness(t, harnessOpts{})
879810 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
R cmd/ermite/cli_binary_test.go → internal/app/cli_binary_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
R cmd/ermite/cli_e2e_test.go → internal/app/cli_e2e_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
R cmd/ermite/cli_fake_test.go → internal/app/cli_fake_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
R cmd/ermite/cli_mr.go → internal/app/cli_mr.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
R cmd/ermite/cli_mr_test.go → internal/app/cli_mr_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "strings"
R cmd/ermite/cli_repo.go → internal/app/cli_repo.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
R cmd/ermite/cli_repo_test.go → internal/app/cli_repo_test.go +50 -36 renamed, 88% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "errors"
@@ -34,41 +34,55 @@ func envFor(url string) map[string]string {
3434 return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}
3535 }
3636
37-func TestRepoListTable(t *testing.T) {
38- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
39- return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
40- }}
41- res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
42- assert.Equal(t, ""+
43- "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+
44- "alpha public main 2026-03-04 05:06 The first one\n"+
45- "beta private trunk never \n", res.stdout)
46- assert.Empty(t, res.stderr)
47- assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
48- assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
49-}
50-
51-func TestRepoListJSON(t *testing.T) {
52- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
53- return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
54- }}
55- res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
56- assert.JSONEq(t, `[
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}
59- ]`, res.stdout)
60-}
61-
62-func TestRepoListEmpty(t *testing.T) {
63- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
64- return &ermite.ListReposResponse{}, nil
65- }}
66- url := f.serve(t)
67- res := runCLI(t, envFor(url), "", "repo", "list").ok(t)
68- assert.Empty(t, res.stdout)
69- assert.Equal(t, "ermite: no repositories\n", res.stderr)
70- res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t)
71- assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null")
37+func TestRepoList(t *testing.T) {
38+ both := []*ermite.Repo{repoAlpha, repoBeta}
39+ tests := []struct {
40+ name string
41+ repos []*ermite.Repo
42+ args []string
43+ wantStdout string
44+ wantJSON bool // compare stdout as JSON rather than byte for byte
45+ wantStderr string
46+ }{
47+ {
48+ name: "table", repos: both, args: []string{"repo", "list"},
49+ wantStdout: "" +
50+ "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" +
51+ "alpha public main 2026-03-04 05:06 The first one\n" +
52+ "beta private trunk never \n",
53+ },
54+ {
55+ name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
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},
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+ ]`,
60+ },
61+ {
62+ name: "empty, table", args: []string{"repo", "list"},
63+ wantStderr: "ermite: no repositories\n",
64+ },
65+ {
66+ name: "empty, json is [] not null", args: []string{"repo", "list", "--json"},
67+ wantStdout: "[]\n",
68+ },
69+ }
70+ for _, tt := range tests {
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+ }
7286 }
7387
7488 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/cli_token.go → internal/app/cli_token.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
R cmd/ermite/cli_token_test.go → internal/app/cli_token_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "testing"
R cmd/ermite/cli_version.go → internal/app/cli_version.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/cli_version_test.go → internal/app/cli_version_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "errors"
R cmd/ermite/cli_watch.go → internal/app/cli_watch.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/cli_watch_test.go → internal/app/cli_watch_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
R cmd/ermite/cli_web.go → internal/app/cli_web.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "errors"
R cmd/ermite/clock.go → internal/app/clock.go +1 -1 renamed, 94% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import "time"
66
R cmd/ermite/datadir.go → internal/app/datadir.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/e2e_helpers_test.go → internal/app/e2e_helpers_test.go +4 -4 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
@@ -18,9 +18,9 @@ import (
1818 "github.com/bnema/zerowrap"
1919 "github.com/stretchr/testify/require"
2020
21- "bnema.dev/ermite/internal/app"
2221 "bnema.dev/ermite/internal/config"
2322 "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
2424 )
2525
2626 // harness is a forge wired like `ermite serve`, behind an httptest server,
@@ -69,7 +69,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
6969 if o.trustedProxies != "" {
7070 env[config.EnvTrustedProxies] = o.trustedProxies
7171 }
72- // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite
72+ // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./internal/app
7373 if r := os.Getenv(config.EnvGitReader); r != "" {
7474 env[config.EnvGitReader] = r
7575 }
@@ -186,7 +186,7 @@ func (h *harness) status(method, path string, hdr http.Header) int {
186186 // token creates a stored token through the use case, as root.
187187 func (h *harness) token(scopes []domain.Scope, repos ...string) string {
188188 h.t.Helper()
189- p := app.CreateTokenParams{Label: "e2e", Scopes: scopes}
189+ p := ports.CreateTokenParams{Label: "e2e", Scopes: scopes}
190190 for _, r := range repos {
191191 p.Repos = append(p.Repos, h.name(r))
192192 }
R cmd/ermite/e2e_test.go → internal/app/e2e_test.go +9 -9 renamed, 97% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
@@ -20,8 +20,8 @@ import (
2020 "github.com/stretchr/testify/assert"
2121 "github.com/stretchr/testify/require"
2222
23- "bnema.dev/ermite/internal/app"
2423 "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
2525 )
2626
2727 func scopes(s ...domain.Scope) []domain.Scope { return s }
@@ -29,7 +29,7 @@ func scopes(s ...domain.Scope) []domain.Scope { return s }
2929 // publicRepo creates a public repository with one commit pushed by root.
3030 func (h *harness) publicRepo(name string, files ...string) {
3131 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})
3333 require.NoError(h.t, err)
3434 wt := h.workTree("main", files...)
3535 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
@@ -189,7 +189,7 @@ func TestAC2PushAuthorization(t *testing.T) {
189189 assert.Contains(t, out, "Authentication failed")
190190
191191 // 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)})
193193 require.NoError(t, err)
194194 next("before-revoke")
195195 out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main")
@@ -570,20 +570,20 @@ func TestAC8TokenCreationThroughTheForge(t *testing.T) {
570570 h := newHarness(t, harnessOpts{})
571571 ctx := context.Background()
572572
573- admin, err := h.forge.Tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
573+ admin, err := h.forge.Tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
574574 Label: "limited admin", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")},
575575 })
576576 require.NoError(t, err)
577577 p, err := h.forge.Auth.Authenticate(ctx, admin.Secret)
578578 require.NoError(t, err)
579579
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")}})
581581 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)})
583583 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")}})
585585 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")}})
587587 require.NoError(t, err)
588588
589589 // 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package app
44
55 import (
66 "context"
77 "errors"
88 "fmt"
9- "sync"
9+ "net/http"
10+ "os/exec"
11+ "syscall"
1012 "testing"
1113 "time"
1214
15+ "connectrpc.com/connect"
1316 "github.com/stretchr/testify/assert"
14- "github.com/stretchr/testify/mock"
1517 "github.com/stretchr/testify/require"
18+ "google.golang.org/protobuf/types/known/fieldmaskpb"
1619
17- "bnema.dev/ermite/internal/app"
20+ pb "bnema.dev/ermite-api/gen/ermite"
1821 "bnema.dev/ermite/internal/domain"
19- "bnema.dev/ermite/internal/ports/mocks"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/usecase"
2024 )
2125
22-// fakeAuth is the Revalidator of the rig: tokens are valid until revoked.
23-type fakeAuth struct {
24- mu sync.Mutex
25- revoked map[string]bool
26- calls int
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())
2768 }
2869
29-func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error {
30- a.mu.Lock()
31- defer a.mu.Unlock()
32- a.calls++
33- if a.revoked[p.TokenID()] {
34- return domain.ErrUnauthenticated
35- }
36- return nil
37-}
38-
39-func (a *fakeAuth) revoke(id string) {
40- a.mu.Lock()
41- defer a.mu.Unlock()
42- if a.revoked == nil {
43- a.revoked = map[string]bool{}
44- }
45- a.revoked[id] = true
46-}
47-
48-func (a *fakeAuth) count() int {
49- a.mu.Lock()
50- defer a.mu.Unlock()
51- return a.calls
52-}
53-
54-// eventRig is an Events use case fed by a channel the test writes to.
55-type eventRig struct {
56- t *testing.T
57- meta *mocks.RepoMetaStore
58- git *mocks.GitRepos
59- feed chan domain.Event
60- auth *fakeAuth
61- events *app.Events
62-}
63-
64-func newEventRig(t *testing.T, size int) *eventRig {
65- t.Helper()
66- return newEventRigConfig(t, app.EventsConfig{Size: size})
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")
67104 }
68105
69-func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {
70- t.Helper()
71- r := &eventRig{
72- t: t,
73- meta: mocks.NewRepoMetaStore(t),
74- git: mocks.NewGitRepos(t),
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-}
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, "")
99112
100-// pushEvent builds the PushEvent the Pushes use case would publish now: it
101-// carries the repository as the store answers at this moment.
102-func (r *eventRig) pushEvent(n domain.RepoName, oid, ref string) domain.PushEvent {
103- repo, err := r.meta.Get(context.Background(), n)
104- require.NoError(r.t, err)
105- return domain.PushEvent{
106- Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: ref}}, At: now,
107- Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
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())
108121 }
109-}
110-
111-func (r *eventRig) push(n domain.RepoName, i int) {
112- r.feed <- r.pushEvent(n, fmt.Sprintf("%040x", i+1), "refs/heads/main")
113-}
114-
115-// watcher is a running Watch.
116-type watcher struct {
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")
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())
141130 }
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())
143144 }
144145
145-// sentinelOID marks the push events that only prove the ones before them were
146-// handled.
147-var sentinelOID = fmt.Sprintf("%040x", 0xfeed)
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")
148153
149-// quiet proves that the watcher got nothing since its last read: it pushes an
150-// event on n, which the watcher may see, and requires it to be the next one.
151-// Events reach a watcher in order, so anything sent before would show up first.
152-func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) {
153- t.Helper()
154- r.feed <- r.pushEvent(n, sentinelOID, "refs/heads/sentinel")
155- e := w.next(t)
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)
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())
159161 }
160-}
161162
162-func TestWatchLiveAndFiltered(t *testing.T) {
163- r := newEventRig(t, 0)
164- pub := r.repo("pub", domain.VisibilityPublic)
165- priv := r.repo("priv", domain.VisibilityPrivate)
166- ctx, cancel := context.WithCancel(context.Background())
167- defer cancel()
168-
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)
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())
183170 }
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)
211171
212- mu.Lock()
213- vis = domain.VisibilityPrivate
214- mu.Unlock()
215- r.push(n, 2)
216- w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
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())
217175 }
218176
219-func TestWatchResumeAndGap(t *testing.T) {
220- const size = 8
221- r := newEventRig(t, size)
222- n := r.repo("pub", domain.VisibilityPublic)
223- ctx, cancel := context.WithCancel(context.Background())
224- defer cancel()
225-
226- // Record the ids of 20 events through a first watcher.
227- first := r.watch(ctx, domain.Root(), app.WatchParams{})
228- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
229- // One at a time: this watcher must not fall behind the small ring.
230- var ids []string
231- for i := range 20 {
232- r.push(n, i)
233- ids = append(ids, first.next(t).ID)
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)
234223 }
235- require.Len(t, ids, 20)
236-
237- // The subtests that read the state of the ring come first: the ones that
238- // need a sentinel add events to it.
239- t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
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)
224+ // Nothing about priv came before the sentinel, for either of them.
225+ _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{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)
305229 }
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- })
337230
338- t.Run("a failing send ends the watch with its error", func(t *testing.T) {
339- r := newEventRig(t, 0)
340- n := r.repo("pub", domain.VisibilityPublic)
341- boom := errors.New("client went away")
342- done := make(chan error, 1)
343- go func() {
344- done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom })
345- }()
346- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
347- r.push(n, 1)
348- assert.ErrorIs(t, <-done, boom)
349- })
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")
350240 }
351241
352-func TestWatchSlowWatcherGetsAGap(t *testing.T) {
353- const size = 4
354- r := newEventRig(t, size)
355- n := r.repo("pub", domain.VisibilityPublic)
356-
357- release := make(chan struct{})
358- var mu sync.Mutex
359- var got []app.WatchEvent
360- done := make(chan error, 1)
361- blocked := make(chan struct{})
362- first := true
363- go func() {
364- done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error {
365- if first { // block on the first event while the ring turns over
366- first = false
367- close(blocked)
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)
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())
408258 }
409259
410-func TestEventsRepoPayloads(t *testing.T) {
411- r := newEventRig(t, 0)
412- pub := r.repo("pub", domain.VisibilityPublic)
413- priv := r.repo("priv", domain.VisibilityPrivate)
414- ctx, cancel := context.WithCancel(context.Background())
415- defer cancel()
416- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
417- root := r.watch(ctx, domain.Root(), app.WatchParams{})
418- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
419-
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)
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")
432270 }
433- assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
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)
271+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond)
437272 }
438273
439-// tokenWith builds the principal of a stored token with an id and an expiry.
440-func tokenWith(id string, expires time.Time, repos ...string) domain.Principal {
441- tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires}
442- for _, r := range repos {
443- n, err := domain.ParseRepoName(r)
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")
444295 if err != nil {
445- panic(err)
296+ return false
446297 }
447- tok.Repos = append(tok.Repos, n)
448- }
449- return domain.TokenPrincipal(tok)
450-}
298+ _ = resp.Body.Close()
299+ return resp.StatusCode == 200
300+ }, 15*time.Second, 50*time.Millisecond, "%s", logs.String())
451301
452-func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
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{})
302+ c := newClients(base+"/-/api", adminToken)
458303 ctx, cancel := context.WithCancel(context.Background())
459304 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+ }()
460313
461- w := r.watch(ctx, p, app.WatchParams{})
462- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
463- r.push(pub, 1)
464- w.next(t)
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())
465318
466- r.auth.revoke("tok")
467- r.push(priv, 2) // would be delivered to a token that may read priv
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- }
319+ start := time.Now()
320+ require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
474321 select {
475- case e := <-w.got:
476- t.Fatalf("an event was sent after the revocation: %+v", e)
477- default:
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())
478326 }
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
488327 select {
489- case err := <-w.done:
490- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
491- case <-time.After(5 * time.Second):
492- t.Fatal("the watch of an expired token did not end")
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())
493332 }
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))
495336 }
496337
497-func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
498- t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
499- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})
500- w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
501- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
502- r.auth.revoke("tok")
503- select {
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- }
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(), ports.CreateTokenParams{
344+ Label: "watcher", Scopes: scopes(domain.ScopeRepoRead),
509345 })
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")
510351
511- t.Run("the answer is kept between checks", func(t *testing.T) {
512- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})
513- pub := r.repo("pub", domain.VisibilityPublic)
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- })
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")
524355
525- t.Run("root and anonymous never ask", func(t *testing.T) {
526- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
527- pub := r.repo("pub", domain.VisibilityPublic)
528- a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{})
529- b := r.watch(context.Background(), domain.Root(), app.WatchParams{})
530- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
531- r.push(pub, 1)
532- a.next(t)
533- b.next(t)
534- assert.Zero(t, r.auth.count())
535- })
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+ }
536365 }
537366
367+// Watch refuses too many topics, and too many streams from one client.
538368 func TestWatchLimits(t *testing.T) {
539- noop := func(app.WatchEvent) error { return nil }
540-
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-}
369+ h := newHarness(t, harnessOpts{})
370+ api := h.api(adminToken)
598371
599-// repoAt declares a repository whose metadata the test changes.
600-type liveRepo struct {
601- mu sync.Mutex
602- repo domain.Repo
603-}
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-}
372+ topics := make([]string, usecase.MaxWatchTopics+1)
373+ for i := range topics {
374+ topics[i] = "home"
375+ }
376+ assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...)))
621377
622-func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {
623- ctx, cancel := context.WithCancel(context.Background())
378+ for range usecase.MaxWatchersPerClient {
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)
624386 defer cancel()
625-
626- t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {
627- r := newEventRig(t, 0)
628- l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
629- root := r.watch(ctx, domain.Root(), app.WatchParams{})
630- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
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- })
387+ registered := false
388+ err := h.forge.EventLog.Watch(ctx, domain.Root(), ports.WatchParams{
389+ Client: "another-client", Ready: func() { registered = true; cancel() },
390+ }, func(ports.WatchEvent) error { return nil })
391+ require.NoError(t, err)
392+ assert.True(t, registered, "a watch of another client address is registered")
714393 }
715394
716-// TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was
717-// when published, not as the store says when the feeder gets to it.
718-func TestPushEventsKeepTheirSnapshot(t *testing.T) {
719- ctx, cancel := context.WithCancel(context.Background())
720- defer cancel()
721-
722- tests := []struct {
723- name string
724- swap func(repo *domain.Repo)
725- rootSees bool // the repository is still the one the push is about
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- })
395+// sentinel proves that nothing reached the stream since the last event it
396+// returned: it creates a public repository, which every watcher of "home" may
397+// see, and requires its RepoCreated to be the next event. Events arrive in the
398+// order they were published.
399+func (h *harness) sentinel(t *testing.T, s *stream, name string) {
400+ t.Helper()
401+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
402+ require.NoError(t, err)
403+ ev := s.next(t, watchWait)
404+ assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev)
767405 }
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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
@@ -24,11 +24,11 @@ import (
2424 "bnema.dev/ermite/internal/adapters/rpc"
2525 "bnema.dev/ermite/internal/adapters/sshsig"
2626 "bnema.dev/ermite/internal/adapters/web"
27- "bnema.dev/ermite/internal/app"
2827 "bnema.dev/ermite/internal/clientlimit"
2928 "bnema.dev/ermite/internal/config"
3029 "bnema.dev/ermite/internal/domain"
3130 "bnema.dev/ermite/internal/ports"
31+ "bnema.dev/ermite/internal/usecase"
3232 )
3333
3434 // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in
@@ -40,13 +40,13 @@ type options struct {
4040 // hookExe is the executable the hook scripts exec; empty means this
4141 // process's own executable.
4242 hookExe string
43- // maintenanceDelay overrides app.MaintenanceDelay when positive.
43+ // maintenanceDelay overrides usecase.MaintenanceDelay when positive.
4444 maintenanceDelay time.Duration
4545 // maxBodyBytes overrides the limit of a push body when positive.
4646 maxBodyBytes int64
47- // eventBuffer overrides app.DefaultEventBuffer when positive.
47+ // eventBuffer overrides usecase.DefaultEventBuffer when positive.
4848 eventBuffer int
49- // revalidateEvery overrides app.DefaultRevalidateEvery when not zero.
49+ // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5050 revalidateEvery time.Duration
5151 }
5252
@@ -56,24 +56,24 @@ type forge struct {
5656 store *kvstore.Store
5757 server *httpserver.Server
5858 hooks *githttp.HookServer
59- maint *app.Maintainer
59+ maint *usecase.Maintainer
6060 git *gitcli.Git
6161 // reader is the ermite-git reader when ERMITE_GIT_READER selects it.
6262 reader *ermitegit.Adapter
6363
6464 // The use cases and the API are exposed for tests and for the phases
6565 // that add the web.
66- Repos *app.Repos
67- Tokens *app.Tokens
68- Auth *app.Auth
69- Pushes *app.Pushes
70- Browse *app.Browse
66+ Repos *usecase.Repos
67+ Tokens *usecase.Tokens
68+ Auth *usecase.Auth
69+ Pushes *usecase.Pushes
70+ Browse *usecase.Browse
7171 // MergeRequests and SigningKey serve MergeRequestService and
7272 // ServerService.GetSigningKey.
73- MergeRequests *app.MergeRequests
74- SigningKey *app.SigningKey
75- Sessions *app.Sessions
76- EventLog *app.Events
73+ MergeRequests *usecase.MergeRequests
74+ SigningKey *usecase.SigningKey
75+ Sessions *usecase.Sessions
76+ EventLog *usecase.Events
7777 API *rpc.API
7878 Bus ports.EventBus
7979 }
@@ -139,7 +139,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
139139
140140 clock := systemClock{}
141141 f.Bus = eventbus.New()
142- f.Repos = app.NewRepos(store, git, f.Bus, clock)
142+ f.Repos = usecase.NewRepos(store, git, f.Bus, clock)
143143 report, err := f.Repos.Reconcile(ctx)
144144 if err != nil {
145145 return nil, fmt.Errorf("reconcile repositories: %w", err)
@@ -150,10 +150,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
150150 Int("invalid_dirs", len(report.InvalidDirs)).
151151 Msg("repositories reconciled")
152152
153- f.Tokens = app.NewTokens(store.Tokens(), clock, store.Sessions())
154- f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
155- f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)
156- f.Pushes = app.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
153+ f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
154+ f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
155+ f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
156+ f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
157157
158158 exe := opts.hookExe
159159 if exe == "" {
@@ -181,8 +181,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
181181 reader = f.reader
182182 }
183183 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
184- f.Browse = app.NewBrowse(store, reader)
185- f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{
184+ f.Browse = usecase.NewBrowse(store, reader)
185+ f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
186186 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
187187 })
188188
@@ -196,14 +196,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
196196 return nil, err
197197 }
198198 committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
199- f.SigningKey = app.NewSigningKey(signer, committer)
200- f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{
199+ f.SigningKey = usecase.NewSigningKey(signer, committer)
200+ f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
201201 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
202202 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
203203 })
204204 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
205205
206- f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
206+ f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
207207 if err != nil {
208208 return nil, err
209209 }
@@ -240,13 +240,15 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
240240 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
241241 }
242242 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)
244245 if err != nil {
245246 return nil, fmt.Errorf("web: %w", err)
246247 }
247248 routes = append(routes, webRoutes(site)...)
248249 f.server = httpserver.New(httpserver.Config{
249250 Addr: cfg.Addr,
251+ PublicURL: cfg.PublicURL,
250252 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
251253 Routes: routes,
252254 API: f.API.Handler(),
R cmd/ermite/gitreader_test.go → internal/app/gitreader_test.go +3 -3 renamed, 94% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
@@ -10,9 +10,9 @@ import (
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/require"
1212
13- "bnema.dev/ermite/internal/app"
1413 "bnema.dev/ermite/internal/config"
1514 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
1616 )
1717
1818 // The suite runs with the reader of ERMITE_GIT_READER (ermite-git by default).
@@ -28,7 +28,7 @@ func TestGitReaderOfTheForgeAndReadAfterPush(t *testing.T) {
2828 assert.Equal(t, want, h.forge.cfg.GitReader)
2929 assert.Equal(t, want == config.GitReaderErmiteGit, h.forge.reader != nil, "the ermite-git adapter exists only for ermite-git")
3030
31- _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{
31+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{
3232 Name: h.name("fresh"), Visibility: domain.VisibilityPublic,
3333 })
3434 require.NoError(t, err)
R cmd/ermite/hook.go → internal/app/hook.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
R cmd/ermite/main_test.go → internal/app/main_test.go +2 -2 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
@@ -43,7 +43,7 @@ func binary(t *testing.T) string {
4343 return
4444 }
4545 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()
4747 if err != nil {
4848 buildErr = errors.New("go build: " + err.Error() + "\n" + string(out))
4949 }
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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
R cmd/ermite/root.go → internal/app/root.go +2 -2 renamed, 93% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "github.com/spf13/cobra"
@@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
2121 "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +
2222 "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",
2323 SilenceUsage: true,
24- SilenceErrors: true, // main prints the error once
24+ SilenceErrors: true, // cmd/ermite prints the error once
2525 }
2626 root.AddCommand(
2727 newServeCmd(), newHookCmd(),
R cmd/ermite/serve.go → internal/app/serve.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -230,7 +231,7 @@
230231 <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()">
231232 <div class="panel-head">
232233 <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)">
234235 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
235236 <kbd>esc</kbd>
236237 </button>
@@ -265,13 +266,7 @@
265266 <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()">
266267 <div class="panel-head">
267268 <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- 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() }">
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)">
275270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
276271 <kbd>esc</kbd>
277272 </button>
@@ -342,67 +337,27 @@
342337 <kbd>esc</kbd> close</span>
343338 </p>
344339 </dialog>
345-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
346- if (q('dialog[open]').count) return;
347- let rows = q('[data-rows] > :not([hidden])').arr();
348- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
349- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
350- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
351- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
352- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
353- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
354- })(1);
355- k-k from:body -> (async (d) => {
356- if (q('dialog[open]').count) return;
357- let rows = q('[data-rows] > :not([hidden])').arr();
358- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
359- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
360- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
361- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
362- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
363- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
364- })(-1);
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));
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- }">
340+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
341+ k-k from:body -> ermite.move(-1);
342+ k-top from:body -> ermite.edge(false);
343+ k-bottom from:body -> ermite.edge(true);
344+ k-open from:body -> ermite.open();
345+ k-esc from:body -> ermite.closeDialogs();
346+ k-help from:body -> ermite.dialog('panel-help');
347+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
348+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
349+ k-filter from:body -> ermite.focusFilter();
350+ k-gh from:body -> ermite.go('.brand');
351+ k-s from:body -> ermite.tab('s');
352+ k-t from:body -> ermite.tab('t');
353+ k-c from:body -> ermite.tab('c');
354+ k-r from:body -> ermite.tab('r');
355+ k-m from:body -> ermite.tab('m');
356+ k-next from:body -> ermite.nextNote();
357+ k-split from:body -> ermite.layout('split');
358+ k-unified from:body -> ermite.layout('unified');
359+ k-collapse from:body -> ermite.collapse();
360+ k-permalink from:body -> ermite.permalink()">
406361 </div>
407362 </body>
408363 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -102,7 +103,7 @@
102103 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
103104 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
104105 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>
106107 <span class="line" id="L1">
107108 <a class="ln" href="#L1">1</a>
108109 <span class="kn">package</span>
@@ -300,7 +301,7 @@
300301 <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()">
301302 <div class="panel-head">
302303 <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)">
304305 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
305306 <kbd>esc</kbd>
306307 </button>
@@ -335,13 +336,7 @@
335336 <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()">
336337 <div class="panel-head">
337338 <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- 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() }">
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)">
345340 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
346341 <kbd>esc</kbd>
347342 </button>
@@ -412,67 +407,27 @@
412407 <kbd>esc</kbd> close</span>
413408 </p>
414409 </dialog>
415-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
416- if (q('dialog[open]').count) return;
417- let rows = q('[data-rows] > :not([hidden])').arr();
418- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
419- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
420- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
421- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
422- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
423- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
424- })(1);
425- k-k from:body -> (async (d) => {
426- if (q('dialog[open]').count) return;
427- let rows = q('[data-rows] > :not([hidden])').arr();
428- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
429- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
430- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
431- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
432- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
433- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
434- })(-1);
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));
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- }">
410+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
411+ k-k from:body -> ermite.move(-1);
412+ k-top from:body -> ermite.edge(false);
413+ k-bottom from:body -> ermite.edge(true);
414+ k-open from:body -> ermite.open();
415+ k-esc from:body -> ermite.closeDialogs();
416+ k-help from:body -> ermite.dialog('panel-help');
417+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
418+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
419+ k-filter from:body -> ermite.focusFilter();
420+ k-gh from:body -> ermite.go('.brand');
421+ k-s from:body -> ermite.tab('s');
422+ k-t from:body -> ermite.tab('t');
423+ k-c from:body -> ermite.tab('c');
424+ k-r from:body -> ermite.tab('r');
425+ k-m from:body -> ermite.tab('m');
426+ k-next from:body -> ermite.nextNote();
427+ k-split from:body -> ermite.layout('split');
428+ k-unified from:body -> ermite.layout('unified');
429+ k-collapse from:body -> ermite.collapse();
430+ k-permalink from:body -> ermite.permalink()">
476431 </div>
477432 </body>
478433 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -230,7 +231,7 @@
230231 <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()">
231232 <div class="panel-head">
232233 <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)">
234235 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
235236 <kbd>esc</kbd>
236237 </button>
@@ -265,13 +266,7 @@
265266 <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()">
266267 <div class="panel-head">
267268 <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- 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() }">
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)">
275270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
276271 <kbd>esc</kbd>
277272 </button>
@@ -342,67 +337,27 @@
342337 <kbd>esc</kbd> close</span>
343338 </p>
344339 </dialog>
345-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
346- if (q('dialog[open]').count) return;
347- let rows = q('[data-rows] > :not([hidden])').arr();
348- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
349- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
350- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
351- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
352- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
353- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
354- })(1);
355- k-k from:body -> (async (d) => {
356- if (q('dialog[open]').count) return;
357- let rows = q('[data-rows] > :not([hidden])').arr();
358- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
359- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
360- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
361- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
362- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
363- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
364- })(-1);
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));
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- }">
340+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
341+ k-k from:body -> ermite.move(-1);
342+ k-top from:body -> ermite.edge(false);
343+ k-bottom from:body -> ermite.edge(true);
344+ k-open from:body -> ermite.open();
345+ k-esc from:body -> ermite.closeDialogs();
346+ k-help from:body -> ermite.dialog('panel-help');
347+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
348+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
349+ k-filter from:body -> ermite.focusFilter();
350+ k-gh from:body -> ermite.go('.brand');
351+ k-s from:body -> ermite.tab('s');
352+ k-t from:body -> ermite.tab('t');
353+ k-c from:body -> ermite.tab('c');
354+ k-r from:body -> ermite.tab('r');
355+ k-m from:body -> ermite.tab('m');
356+ k-next from:body -> ermite.nextNote();
357+ k-split from:body -> ermite.layout('split');
358+ k-unified from:body -> ermite.layout('unified');
359+ k-collapse from:body -> ermite.collapse();
360+ k-permalink from:body -> ermite.permalink()">
406361 </div>
407362 </body>
408363 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -232,7 +233,7 @@
232233 <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()">
233234 <div class="panel-head">
234235 <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)">
236237 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
237238 <kbd>esc</kbd>
238239 </button>
@@ -267,13 +268,7 @@
267268 <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()">
268269 <div class="panel-head">
269270 <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- 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() }">
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)">
277272 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
278273 <kbd>esc</kbd>
279274 </button>
@@ -344,67 +339,27 @@
344339 <kbd>esc</kbd> close</span>
345340 </p>
346341 </dialog>
347-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
348- if (q('dialog[open]').count) return;
349- let rows = q('[data-rows] > :not([hidden])').arr();
350- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
351- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
352- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
353- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
354- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
355- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
356- })(1);
357- k-k from:body -> (async (d) => {
358- if (q('dialog[open]').count) return;
359- let rows = q('[data-rows] > :not([hidden])').arr();
360- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
361- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
362- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
363- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
364- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
365- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
366- })(-1);
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));
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- }">
342+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
343+ k-k from:body -> ermite.move(-1);
344+ k-top from:body -> ermite.edge(false);
345+ k-bottom from:body -> ermite.edge(true);
346+ k-open from:body -> ermite.open();
347+ k-esc from:body -> ermite.closeDialogs();
348+ k-help from:body -> ermite.dialog('panel-help');
349+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
350+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
351+ k-filter from:body -> ermite.focusFilter();
352+ k-gh from:body -> ermite.go('.brand');
353+ k-s from:body -> ermite.tab('s');
354+ k-t from:body -> ermite.tab('t');
355+ k-c from:body -> ermite.tab('c');
356+ k-r from:body -> ermite.tab('r');
357+ k-m from:body -> ermite.tab('m');
358+ k-next from:body -> ermite.nextNote();
359+ k-split from:body -> ermite.layout('split');
360+ k-unified from:body -> ermite.layout('unified');
361+ k-collapse from:body -> ermite.collapse();
362+ k-permalink from:body -> ermite.permalink()">
408363 </div>
409364 </body>
410365 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -48,19 +49,19 @@
4849 </ol>
4950 </nav>
5051 <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)">
5253 <span class="ico i-branch" aria-hidden="true">
5354 </span>
5455 <span class="ref-name">main</span>
5556 <span class="sha">SHA7</span>
56-</button>
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'))">
57+</a>
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">
5859 <span class="ico i-search" aria-hidden="true">
5960 </span>
6061 <span class="search-label">Go to…</span>
6162 <kbd aria-hidden="true">ctrl k</kbd>
6263 </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>
6465 </div>
6566 </div>
6667 <nav class="tabs" aria-label="Sections">
@@ -98,7 +99,7 @@
9899 <a class="btn" href="/files/tree/main/docs">Folder</a>
99100 </span>
100101 </h1>
101-<article class="prose" aria-label="guide.md">
102+<article class="prose" aria-label="guide.md" hx-ignore>
102103 <h1 id="md-guide">Guide</h1>
103104 <h2 id="md-steps">Steps</h2>
104105 <p>Run it.</p>
@@ -116,7 +117,7 @@
116117 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
117118 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
118119 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>
120121 <span class="line" id="L1">
121122 <a class="ln" href="#L1">1</a>
122123 <span class="gh"># Guide</span>
@@ -297,7 +298,7 @@
297298 <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()">
298299 <div class="panel-head">
299300 <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)">
301302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
302303 <kbd>esc</kbd>
303304 </button>
@@ -332,13 +333,7 @@
332333 <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()">
333334 <div class="panel-head">
334335 <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- 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() }">
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)">
342337 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
343338 <kbd>esc</kbd>
344339 </button>
@@ -409,67 +404,27 @@
409404 <kbd>esc</kbd> close</span>
410405 </p>
411406 </dialog>
412-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
413- if (q('dialog[open]').count) return;
414- let rows = q('[data-rows] > :not([hidden])').arr();
415- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
416- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
417- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
418- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
419- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
420- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
421- })(1);
422- k-k from:body -> (async (d) => {
423- if (q('dialog[open]').count) return;
424- let rows = q('[data-rows] > :not([hidden])').arr();
425- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
426- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
427- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
428- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
429- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
430- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
431- })(-1);
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));
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- }">
407+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
408+ k-k from:body -> ermite.move(-1);
409+ k-top from:body -> ermite.edge(false);
410+ k-bottom from:body -> ermite.edge(true);
411+ k-open from:body -> ermite.open();
412+ k-esc from:body -> ermite.closeDialogs();
413+ k-help from:body -> ermite.dialog('panel-help');
414+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
415+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
416+ k-filter from:body -> ermite.focusFilter();
417+ k-gh from:body -> ermite.go('.brand');
418+ k-s from:body -> ermite.tab('s');
419+ k-t from:body -> ermite.tab('t');
420+ k-c from:body -> ermite.tab('c');
421+ k-r from:body -> ermite.tab('r');
422+ k-m from:body -> ermite.tab('m');
423+ k-next from:body -> ermite.nextNote();
424+ k-split from:body -> ermite.layout('split');
425+ k-unified from:body -> ermite.layout('unified');
426+ k-collapse from:body -> ermite.collapse();
427+ k-permalink from:body -> ermite.permalink()">
473428 </div>
474429 </body>
475430 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -102,7 +103,7 @@
102103 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
103104 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
104105 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>
106107 <span class="line" id="L1">
107108 <a class="ln" href="#L1">1</a>
108109 <span class="nt">&lt;svg</span>
@@ -255,7 +256,7 @@
255256 <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()">
256257 <div class="panel-head">
257258 <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)">
259260 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
260261 <kbd>esc</kbd>
261262 </button>
@@ -290,13 +291,7 @@
290291 <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()">
291292 <div class="panel-head">
292293 <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- 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() }">
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)">
300295 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
301296 <kbd>esc</kbd>
302297 </button>
@@ -367,67 +362,27 @@
367362 <kbd>esc</kbd> close</span>
368363 </p>
369364 </dialog>
370-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
371- if (q('dialog[open]').count) return;
372- let rows = q('[data-rows] > :not([hidden])').arr();
373- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
374- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
375- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
376- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
377- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
378- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
379- })(1);
380- k-k from:body -> (async (d) => {
381- if (q('dialog[open]').count) return;
382- let rows = q('[data-rows] > :not([hidden])').arr();
383- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
384- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
385- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
386- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
387- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
388- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
389- })(-1);
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));
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- }">
365+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
366+ k-k from:body -> ermite.move(-1);
367+ k-top from:body -> ermite.edge(false);
368+ k-bottom from:body -> ermite.edge(true);
369+ k-open from:body -> ermite.open();
370+ k-esc from:body -> ermite.closeDialogs();
371+ k-help from:body -> ermite.dialog('panel-help');
372+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
373+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
374+ k-filter from:body -> ermite.focusFilter();
375+ k-gh from:body -> ermite.go('.brand');
376+ k-s from:body -> ermite.tab('s');
377+ k-t from:body -> ermite.tab('t');
378+ k-c from:body -> ermite.tab('c');
379+ k-r from:body -> ermite.tab('r');
380+ k-m from:body -> ermite.tab('m');
381+ k-next from:body -> ermite.nextNote();
382+ k-split from:body -> ermite.layout('split');
383+ k-unified from:body -> ermite.layout('unified');
384+ k-collapse from:body -> ermite.collapse();
385+ k-permalink from:body -> ermite.permalink()">
431386 </div>
432387 </body>
433388 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -102,7 +103,7 @@
102103 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
103104 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
104105 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>
106107 <span class="line" id="L1">
107108 <a class="ln" href="#L1">1</a>plain notes</span>
108109 <span class="line" id="L2">
@@ -250,7 +251,7 @@
250251 <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()">
251252 <div class="panel-head">
252253 <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)">
254255 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
255256 <kbd>esc</kbd>
256257 </button>
@@ -285,13 +286,7 @@
285286 <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()">
286287 <div class="panel-head">
287288 <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- 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() }">
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)">
295290 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
296291 <kbd>esc</kbd>
297292 </button>
@@ -362,67 +357,27 @@
362357 <kbd>esc</kbd> close</span>
363358 </p>
364359 </dialog>
365-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
366- if (q('dialog[open]').count) return;
367- let rows = q('[data-rows] > :not([hidden])').arr();
368- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
369- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
370- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
371- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
372- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
373- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
374- })(1);
375- k-k from:body -> (async (d) => {
376- if (q('dialog[open]').count) return;
377- let rows = q('[data-rows] > :not([hidden])').arr();
378- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
379- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
380- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
381- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
382- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
383- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
384- })(-1);
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));
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- }">
360+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
361+ k-k from:body -> ermite.move(-1);
362+ k-top from:body -> ermite.edge(false);
363+ k-bottom from:body -> ermite.edge(true);
364+ k-open from:body -> ermite.open();
365+ k-esc from:body -> ermite.closeDialogs();
366+ k-help from:body -> ermite.dialog('panel-help');
367+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
368+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
369+ k-filter from:body -> ermite.focusFilter();
370+ k-gh from:body -> ermite.go('.brand');
371+ k-s from:body -> ermite.tab('s');
372+ k-t from:body -> ermite.tab('t');
373+ k-c from:body -> ermite.tab('c');
374+ k-r from:body -> ermite.tab('r');
375+ k-m from:body -> ermite.tab('m');
376+ k-next from:body -> ermite.nextNote();
377+ k-split from:body -> ermite.layout('split');
378+ k-unified from:body -> ermite.layout('unified');
379+ k-collapse from:body -> ermite.collapse();
380+ k-permalink from:body -> ermite.permalink()">
426381 </div>
427382 </body>
428383 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -230,7 +231,7 @@
230231 <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()">
231232 <div class="panel-head">
232233 <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)">
234235 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
235236 <kbd>esc</kbd>
236237 </button>
@@ -265,13 +266,7 @@
265266 <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()">
266267 <div class="panel-head">
267268 <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- 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() }">
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)">
275270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
276271 <kbd>esc</kbd>
277272 </button>
@@ -342,67 +337,27 @@
342337 <kbd>esc</kbd> close</span>
343338 </p>
344339 </dialog>
345-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
346- if (q('dialog[open]').count) return;
347- let rows = q('[data-rows] > :not([hidden])').arr();
348- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
349- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
350- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
351- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
352- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
353- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
354- })(1);
355- k-k from:body -> (async (d) => {
356- if (q('dialog[open]').count) return;
357- let rows = q('[data-rows] > :not([hidden])').arr();
358- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
359- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
360- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
361- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
362- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
363- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
364- })(-1);
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));
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- }">
340+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
341+ k-k from:body -> ermite.move(-1);
342+ k-top from:body -> ermite.edge(false);
343+ k-bottom from:body -> ermite.edge(true);
344+ k-open from:body -> ermite.open();
345+ k-esc from:body -> ermite.closeDialogs();
346+ k-help from:body -> ermite.dialog('panel-help');
347+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
348+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
349+ k-filter from:body -> ermite.focusFilter();
350+ k-gh from:body -> ermite.go('.brand');
351+ k-s from:body -> ermite.tab('s');
352+ k-t from:body -> ermite.tab('t');
353+ k-c from:body -> ermite.tab('c');
354+ k-r from:body -> ermite.tab('r');
355+ k-m from:body -> ermite.tab('m');
356+ k-next from:body -> ermite.nextNote();
357+ k-split from:body -> ermite.layout('split');
358+ k-unified from:body -> ermite.layout('unified');
359+ k-collapse from:body -> ermite.collapse();
360+ k-permalink from:body -> ermite.permalink()">
406361 </div>
407362 </body>
408363 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -102,7 +103,7 @@
102103 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
103104 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
104105 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>
106107 <span class="line" id="L1">
107108 <a class="ln" href="#L1">1</a>
108109 <span class="kn">package</span>
@@ -297,7 +298,7 @@
297298 <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()">
298299 <div class="panel-head">
299300 <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)">
301302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
302303 <kbd>esc</kbd>
303304 </button>
@@ -332,13 +333,7 @@
332333 <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()">
333334 <div class="panel-head">
334335 <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- 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() }">
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)">
342337 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
343338 <kbd>esc</kbd>
344339 </button>
@@ -409,67 +404,27 @@
409404 <kbd>esc</kbd> close</span>
410405 </p>
411406 </dialog>
412-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
413- if (q('dialog[open]').count) return;
414- let rows = q('[data-rows] > :not([hidden])').arr();
415- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
416- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
417- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
418- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
419- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
420- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
421- })(1);
422- k-k from:body -> (async (d) => {
423- if (q('dialog[open]').count) return;
424- let rows = q('[data-rows] > :not([hidden])').arr();
425- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
426- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
427- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
428- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
429- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
430- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
431- })(-1);
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));
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- }">
407+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
408+ k-k from:body -> ermite.move(-1);
409+ k-top from:body -> ermite.edge(false);
410+ k-bottom from:body -> ermite.edge(true);
411+ k-open from:body -> ermite.open();
412+ k-esc from:body -> ermite.closeDialogs();
413+ k-help from:body -> ermite.dialog('panel-help');
414+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
415+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
416+ k-filter from:body -> ermite.focusFilter();
417+ k-gh from:body -> ermite.go('.brand');
418+ k-s from:body -> ermite.tab('s');
419+ k-t from:body -> ermite.tab('t');
420+ k-c from:body -> ermite.tab('c');
421+ k-r from:body -> ermite.tab('r');
422+ k-m from:body -> ermite.tab('m');
423+ k-next from:body -> ermite.nextNote();
424+ k-split from:body -> ermite.layout('split');
425+ k-unified from:body -> ermite.layout('unified');
426+ k-collapse from:body -> ermite.collapse();
427+ k-permalink from:body -> ermite.permalink()">
473428 </div>
474429 </body>
475430 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -107,11 +108,11 @@
107108 </span>
108109 <span class="grow">
109110 </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>
111112 </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>
113114 </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">
115116 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
116117 <kbd aria-hidden="true">x</kbd>
117118 </button>
@@ -136,7 +137,7 @@
136137 </h2>
137138 <ol class="files" aria-label="Changed files">
138139 <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">
140141 <span class="st" title="A">A</span>
141142 <span class="p" title="big.txt">big.txt</span>
142143 <span class="n">
@@ -267,7 +268,7 @@
267268 <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()">
268269 <div class="panel-head">
269270 <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)">
271272 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
272273 <kbd>esc</kbd>
273274 </button>
@@ -302,13 +303,7 @@
302303 <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()">
303304 <div class="panel-head">
304305 <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- 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() }">
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)">
312307 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
313308 <kbd>esc</kbd>
314309 </button>
@@ -379,67 +374,27 @@
379374 <kbd>esc</kbd> close</span>
380375 </p>
381376 </dialog>
382-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
383- if (q('dialog[open]').count) return;
384- let rows = q('[data-rows] > :not([hidden])').arr();
385- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
386- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
387- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
388- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
389- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
390- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
391- })(1);
392- k-k from:body -> (async (d) => {
393- if (q('dialog[open]').count) return;
394- let rows = q('[data-rows] > :not([hidden])').arr();
395- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
396- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
397- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
398- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
399- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
400- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
401- })(-1);
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));
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- }">
377+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
378+ k-k from:body -> ermite.move(-1);
379+ k-top from:body -> ermite.edge(false);
380+ k-bottom from:body -> ermite.edge(true);
381+ k-open from:body -> ermite.open();
382+ k-esc from:body -> ermite.closeDialogs();
383+ k-help from:body -> ermite.dialog('panel-help');
384+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
385+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
386+ k-filter from:body -> ermite.focusFilter();
387+ k-gh from:body -> ermite.go('.brand');
388+ k-s from:body -> ermite.tab('s');
389+ k-t from:body -> ermite.tab('t');
390+ k-c from:body -> ermite.tab('c');
391+ k-r from:body -> ermite.tab('r');
392+ k-m from:body -> ermite.tab('m');
393+ k-next from:body -> ermite.nextNote();
394+ k-split from:body -> ermite.layout('split');
395+ k-unified from:body -> ermite.layout('unified');
396+ k-collapse from:body -> ermite.collapse();
397+ k-permalink from:body -> ermite.permalink()">
443398 </div>
444399 </body>
445400 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -103,11 +104,11 @@
103104 </span>
104105 <span class="grow">
105106 </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>
107108 </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>
109110 </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">
111112 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
112113 <kbd aria-hidden="true">x</kbd>
113114 </button>
@@ -131,7 +132,7 @@
131132 </h2>
132133 <ol class="files" aria-label="Changed files">
133134 <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">
135136 <span class="st" title="M">M</span>
136137 <span class="p" title="logo.bin">logo.bin</span>
137138 <span class="n">
@@ -262,7 +263,7 @@
262263 <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()">
263264 <div class="panel-head">
264265 <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)">
266267 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
267268 <kbd>esc</kbd>
268269 </button>
@@ -297,13 +298,7 @@
297298 <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()">
298299 <div class="panel-head">
299300 <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- 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() }">
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)">
307302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
308303 <kbd>esc</kbd>
309304 </button>
@@ -374,67 +369,27 @@
374369 <kbd>esc</kbd> close</span>
375370 </p>
376371 </dialog>
377-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
378- if (q('dialog[open]').count) return;
379- let rows = q('[data-rows] > :not([hidden])').arr();
380- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
381- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
382- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
383- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
384- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
385- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
386- })(1);
387- k-k from:body -> (async (d) => {
388- if (q('dialog[open]').count) return;
389- let rows = q('[data-rows] > :not([hidden])').arr();
390- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
391- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
392- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
393- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
394- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
395- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
396- })(-1);
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));
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- }">
372+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
373+ k-k from:body -> ermite.move(-1);
374+ k-top from:body -> ermite.edge(false);
375+ k-bottom from:body -> ermite.edge(true);
376+ k-open from:body -> ermite.open();
377+ k-esc from:body -> ermite.closeDialogs();
378+ k-help from:body -> ermite.dialog('panel-help');
379+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
380+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
381+ k-filter from:body -> ermite.focusFilter();
382+ k-gh from:body -> ermite.go('.brand');
383+ k-s from:body -> ermite.tab('s');
384+ k-t from:body -> ermite.tab('t');
385+ k-c from:body -> ermite.tab('c');
386+ k-r from:body -> ermite.tab('r');
387+ k-m from:body -> ermite.tab('m');
388+ k-next from:body -> ermite.nextNote();
389+ k-split from:body -> ermite.layout('split');
390+ k-unified from:body -> ermite.layout('unified');
391+ k-collapse from:body -> ermite.collapse();
392+ k-permalink from:body -> ermite.permalink()">
438393 </div>
439394 </body>
440395 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -102,11 +103,11 @@
102103 </span>
103104 <span class="grow">
104105 </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>
106107 </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>
108109 </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">
110111 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
111112 <kbd aria-hidden="true">x</kbd>
112113 </button>
@@ -170,7 +171,7 @@
170171 </h2>
171172 <ol class="files" aria-label="Changed files">
172173 <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">
174175 <span class="st" title="A">A</span>
175176 <span class="p" title="side.txt">side.txt</span>
176177 <span class="n">
@@ -301,7 +302,7 @@
301302 <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()">
302303 <div class="panel-head">
303304 <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)">
305306 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
306307 <kbd>esc</kbd>
307308 </button>
@@ -336,13 +337,7 @@
336337 <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()">
337338 <div class="panel-head">
338339 <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- 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() }">
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)">
346341 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
347342 <kbd>esc</kbd>
348343 </button>
@@ -413,67 +408,27 @@
413408 <kbd>esc</kbd> close</span>
414409 </p>
415410 </dialog>
416-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
417- if (q('dialog[open]').count) return;
418- let rows = q('[data-rows] > :not([hidden])').arr();
419- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
420- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
421- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
422- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
423- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
424- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
425- })(1);
426- k-k from:body -> (async (d) => {
427- if (q('dialog[open]').count) return;
428- let rows = q('[data-rows] > :not([hidden])').arr();
429- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
430- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
431- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
432- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
433- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
434- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
435- })(-1);
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));
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- }">
411+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
412+ k-k from:body -> ermite.move(-1);
413+ k-top from:body -> ermite.edge(false);
414+ k-bottom from:body -> ermite.edge(true);
415+ k-open from:body -> ermite.open();
416+ k-esc from:body -> ermite.closeDialogs();
417+ k-help from:body -> ermite.dialog('panel-help');
418+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
419+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
420+ k-filter from:body -> ermite.focusFilter();
421+ k-gh from:body -> ermite.go('.brand');
422+ k-s from:body -> ermite.tab('s');
423+ k-t from:body -> ermite.tab('t');
424+ k-c from:body -> ermite.tab('c');
425+ k-r from:body -> ermite.tab('r');
426+ k-m from:body -> ermite.tab('m');
427+ k-next from:body -> ermite.nextNote();
428+ k-split from:body -> ermite.layout('split');
429+ k-unified from:body -> ermite.layout('unified');
430+ k-collapse from:body -> ermite.collapse();
431+ k-permalink from:body -> ermite.permalink()">
477432 </div>
478433 </body>
479434 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -109,11 +110,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
109110 </span>
110111 <span class="grow">
111112 </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>
113114 </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>
115116 </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">
117118 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
118119 <kbd aria-hidden="true">x</kbd>
119120 </button>
@@ -277,7 +278,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
277278 </h2>
278279 <ol class="files" aria-label="Changed files">
279280 <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">
281282 <span class="st" title="M">M</span>
282283 <span class="p" title="a.txt">a.txt</span>
283284 <span class="n">
@@ -291,7 +292,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
291292 </a>
292293 </li>
293294 <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">
295296 <span class="st" title="M">M</span>
296297 <span class="p" title="run.sh">run.sh</span>
297298 <span class="n">
@@ -422,7 +423,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
422423 <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()">
423424 <div class="panel-head">
424425 <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)">
426427 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
427428 <kbd>esc</kbd>
428429 </button>
@@ -457,13 +458,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
457458 <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()">
458459 <div class="panel-head">
459460 <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- 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() }">
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)">
467462 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
468463 <kbd>esc</kbd>
469464 </button>
@@ -534,67 +529,27 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
534529 <kbd>esc</kbd> close</span>
535530 </p>
536531 </dialog>
537-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
538- if (q('dialog[open]').count) return;
539- let rows = q('[data-rows] > :not([hidden])').arr();
540- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
541- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
542- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
543- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
544- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
545- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
546- })(1);
547- k-k from:body -> (async (d) => {
548- if (q('dialog[open]').count) return;
549- let rows = q('[data-rows] > :not([hidden])').arr();
550- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
551- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
552- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
553- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
554- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
555- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
556- })(-1);
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));
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- }">
532+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
533+ k-k from:body -> ermite.move(-1);
534+ k-top from:body -> ermite.edge(false);
535+ k-bottom from:body -> ermite.edge(true);
536+ k-open from:body -> ermite.open();
537+ k-esc from:body -> ermite.closeDialogs();
538+ k-help from:body -> ermite.dialog('panel-help');
539+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
540+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
541+ k-filter from:body -> ermite.focusFilter();
542+ k-gh from:body -> ermite.go('.brand');
543+ k-s from:body -> ermite.tab('s');
544+ k-t from:body -> ermite.tab('t');
545+ k-c from:body -> ermite.tab('c');
546+ k-r from:body -> ermite.tab('r');
547+ k-m from:body -> ermite.tab('m');
548+ k-next from:body -> ermite.nextNote();
549+ k-split from:body -> ermite.layout('split');
550+ k-unified from:body -> ermite.layout('unified');
551+ k-collapse from:body -> ermite.collapse();
552+ k-permalink from:body -> ermite.permalink()">
598553 </div>
599554 </body>
600555 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -109,11 +110,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
109110 </span>
110111 <span class="grow">
111112 </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>
113114 </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>
115116 </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">
117118 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
118119 <kbd aria-hidden="true">x</kbd>
119120 </button>
@@ -277,7 +278,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
277278 </h2>
278279 <ol class="files" aria-label="Changed files">
279280 <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">
281282 <span class="st" title="M">M</span>
282283 <span class="p" title="a.txt">a.txt</span>
283284 <span class="n">
@@ -291,7 +292,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
291292 </a>
292293 </li>
293294 <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">
295296 <span class="st" title="M">M</span>
296297 <span class="p" title="run.sh">run.sh</span>
297298 <span class="n">
@@ -422,7 +423,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
422423 <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()">
423424 <div class="panel-head">
424425 <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)">
426427 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
427428 <kbd>esc</kbd>
428429 </button>
@@ -457,13 +458,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
457458 <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()">
458459 <div class="panel-head">
459460 <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- 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() }">
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)">
467462 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
468463 <kbd>esc</kbd>
469464 </button>
@@ -534,67 +529,27 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
534529 <kbd>esc</kbd> close</span>
535530 </p>
536531 </dialog>
537-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
538- if (q('dialog[open]').count) return;
539- let rows = q('[data-rows] > :not([hidden])').arr();
540- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
541- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
542- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
543- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
544- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
545- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
546- })(1);
547- k-k from:body -> (async (d) => {
548- if (q('dialog[open]').count) return;
549- let rows = q('[data-rows] > :not([hidden])').arr();
550- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
551- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
552- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
553- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
554- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
555- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
556- })(-1);
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));
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- }">
532+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
533+ k-k from:body -> ermite.move(-1);
534+ k-top from:body -> ermite.edge(false);
535+ k-bottom from:body -> ermite.edge(true);
536+ k-open from:body -> ermite.open();
537+ k-esc from:body -> ermite.closeDialogs();
538+ k-help from:body -> ermite.dialog('panel-help');
539+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
540+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
541+ k-filter from:body -> ermite.focusFilter();
542+ k-gh from:body -> ermite.go('.brand');
543+ k-s from:body -> ermite.tab('s');
544+ k-t from:body -> ermite.tab('t');
545+ k-c from:body -> ermite.tab('c');
546+ k-r from:body -> ermite.tab('r');
547+ k-m from:body -> ermite.tab('m');
548+ k-next from:body -> ermite.nextNote();
549+ k-split from:body -> ermite.layout('split');
550+ k-unified from:body -> ermite.layout('unified');
551+ k-collapse from:body -> ermite.collapse();
552+ k-permalink from:body -> ermite.permalink()">
598553 </div>
599554 </body>
600555 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -103,11 +104,11 @@
103104 </span>
104105 <span class="grow">
105106 </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>
107108 </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>
109110 </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">
111112 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
112113 <kbd aria-hidden="true">x</kbd>
113114 </button>
@@ -181,7 +182,7 @@
181182 </h2>
182183 <ol class="files" aria-label="Changed files">
183184 <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">
185186 <span class="st" title="R">R</span>
186187 <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span>
187188 <span class="n">
@@ -196,7 +197,7 @@
196197 </a>
197198 </li>
198199 <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">
200201 <span class="st" title="D">D</span>
201202 <span class="p" title="gone.txt">gone.txt</span>
202203 <span class="n">
@@ -327,7 +328,7 @@
327328 <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()">
328329 <div class="panel-head">
329330 <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)">
331332 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
332333 <kbd>esc</kbd>
333334 </button>
@@ -362,13 +363,7 @@
362363 <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()">
363364 <div class="panel-head">
364365 <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- 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() }">
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)">
372367 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
373368 <kbd>esc</kbd>
374369 </button>
@@ -439,67 +434,27 @@
439434 <kbd>esc</kbd> close</span>
440435 </p>
441436 </dialog>
442-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
443- if (q('dialog[open]').count) return;
444- let rows = q('[data-rows] > :not([hidden])').arr();
445- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
446- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
447- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
448- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
449- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
450- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
451- })(1);
452- k-k from:body -> (async (d) => {
453- if (q('dialog[open]').count) return;
454- let rows = q('[data-rows] > :not([hidden])').arr();
455- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
456- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
457- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
458- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
459- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
460- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
461- })(-1);
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));
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- }">
437+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
438+ k-k from:body -> ermite.move(-1);
439+ k-top from:body -> ermite.edge(false);
440+ k-bottom from:body -> ermite.edge(true);
441+ k-open from:body -> ermite.open();
442+ k-esc from:body -> ermite.closeDialogs();
443+ k-help from:body -> ermite.dialog('panel-help');
444+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
445+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
446+ k-filter from:body -> ermite.focusFilter();
447+ k-gh from:body -> ermite.go('.brand');
448+ k-s from:body -> ermite.tab('s');
449+ k-t from:body -> ermite.tab('t');
450+ k-c from:body -> ermite.tab('c');
451+ k-r from:body -> ermite.tab('r');
452+ k-m from:body -> ermite.tab('m');
453+ k-next from:body -> ermite.nextNote();
454+ k-split from:body -> ermite.layout('split');
455+ k-unified from:body -> ermite.layout('unified');
456+ k-collapse from:body -> ermite.collapse();
457+ k-permalink from:body -> ermite.permalink()">
503458 </div>
504459 </body>
505460 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -101,11 +102,11 @@
101102 </span>
102103 <span class="grow">
103104 </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>
105106 </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>
107108 </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">
109110 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
110111 <kbd aria-hidden="true">x</kbd>
111112 </button>
@@ -455,7 +456,7 @@
455456 </h2>
456457 <ol class="files" aria-label="Changed files">
457458 <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">
459460 <span class="st" title="A">A</span>
460461 <span class="p" title="a.txt">a.txt</span>
461462 <span class="n">
@@ -470,7 +471,7 @@
470471 </a>
471472 </li>
472473 <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">
474475 <span class="st" title="A">A</span>
475476 <span class="p" title="gone.txt">gone.txt</span>
476477 <span class="n">
@@ -484,7 +485,7 @@
484485 </a>
485486 </li>
486487 <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">
488489 <span class="st" title="A">A</span>
489490 <span class="p" title="logo.bin">logo.bin</span>
490491 <span class="n">
@@ -499,7 +500,7 @@
499500 </a>
500501 </li>
501502 <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">
503504 <span class="st" title="A">A</span>
504505 <span class="p" title="old.txt">old.txt</span>
505506 <span class="n">
@@ -513,7 +514,7 @@
513514 </a>
514515 </li>
515516 <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">
517518 <span class="st" title="A">A</span>
518519 <span class="p" title="run.sh">run.sh</span>
519520 <span class="n">
@@ -643,7 +644,7 @@
643644 <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()">
644645 <div class="panel-head">
645646 <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)">
647648 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
648649 <kbd>esc</kbd>
649650 </button>
@@ -678,13 +679,7 @@
678679 <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()">
679680 <div class="panel-head">
680681 <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- 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() }">
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)">
688683 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
689684 <kbd>esc</kbd>
690685 </button>
@@ -755,67 +750,27 @@
755750 <kbd>esc</kbd> close</span>
756751 </p>
757752 </dialog>
758-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
759- if (q('dialog[open]').count) return;
760- let rows = q('[data-rows] > :not([hidden])').arr();
761- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
762- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
763- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
764- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
765- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
766- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
767- })(1);
768- k-k from:body -> (async (d) => {
769- if (q('dialog[open]').count) return;
770- let rows = q('[data-rows] > :not([hidden])').arr();
771- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
772- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
773- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
774- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
775- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
776- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
777- })(-1);
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));
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- }">
753+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
754+ k-k from:body -> ermite.move(-1);
755+ k-top from:body -> ermite.edge(false);
756+ k-bottom from:body -> ermite.edge(true);
757+ k-open from:body -> ermite.open();
758+ k-esc from:body -> ermite.closeDialogs();
759+ k-help from:body -> ermite.dialog('panel-help');
760+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
761+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
762+ k-filter from:body -> ermite.focusFilter();
763+ k-gh from:body -> ermite.go('.brand');
764+ k-s from:body -> ermite.tab('s');
765+ k-t from:body -> ermite.tab('t');
766+ k-c from:body -> ermite.tab('c');
767+ k-r from:body -> ermite.tab('r');
768+ k-m from:body -> ermite.tab('m');
769+ k-next from:body -> ermite.nextNote();
770+ k-split from:body -> ermite.layout('split');
771+ k-unified from:body -> ermite.layout('unified');
772+ k-collapse from:body -> ermite.collapse();
773+ k-permalink from:body -> ermite.permalink()">
819774 </div>
820775 </body>
821776 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">detached</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -104,11 +105,11 @@
104105 </span>
105106 <span class="grow">
106107 </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>
108109 </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>
110111 </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">
112113 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
113114 <kbd aria-hidden="true">x</kbd>
114115 </button>
@@ -305,7 +306,7 @@
305306 </h2>
306307 <ol class="files" aria-label="Changed files">
307308 <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">
309310 <span class="st" title="A">A</span>
310311 <span class="p" title="evil.go">evil.go</span>
311312 <span class="n">
@@ -436,7 +437,7 @@
436437 <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()">
437438 <div class="panel-head">
438439 <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)">
440441 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
441442 <kbd>esc</kbd>
442443 </button>
@@ -471,13 +472,7 @@
471472 <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()">
472473 <div class="panel-head">
473474 <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- 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() }">
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)">
481476 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
482477 <kbd>esc</kbd>
483478 </button>
@@ -548,67 +543,27 @@
548543 <kbd>esc</kbd> close</span>
549544 </p>
550545 </dialog>
551-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
552- if (q('dialog[open]').count) return;
553- let rows = q('[data-rows] > :not([hidden])').arr();
554- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
555- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
556- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
557- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
558- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
559- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
560- })(1);
561- k-k from:body -> (async (d) => {
562- if (q('dialog[open]').count) return;
563- let rows = q('[data-rows] > :not([hidden])').arr();
564- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
565- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
566- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
567- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
568- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
569- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
570- })(-1);
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));
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- }">
546+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
547+ k-k from:body -> ermite.move(-1);
548+ k-top from:body -> ermite.edge(false);
549+ k-bottom from:body -> ermite.edge(true);
550+ k-open from:body -> ermite.open();
551+ k-esc from:body -> ermite.closeDialogs();
552+ k-help from:body -> ermite.dialog('panel-help');
553+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
554+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
555+ k-filter from:body -> ermite.focusFilter();
556+ k-gh from:body -> ermite.go('.brand');
557+ k-s from:body -> ermite.tab('s');
558+ k-t from:body -> ermite.tab('t');
559+ k-c from:body -> ermite.tab('c');
560+ k-r from:body -> ermite.tab('r');
561+ k-m from:body -> ermite.tab('m');
562+ k-next from:body -> ermite.nextNote();
563+ k-split from:body -> ermite.layout('split');
564+ k-unified from:body -> ermite.layout('unified');
565+ k-collapse from:body -> ermite.collapse();
566+ k-permalink from:body -> ermite.permalink()">
612567 </div>
613568 </body>
614569 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">main</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -86,9 +87,7 @@
8687 <main id="main" tabindex="-1">
8788 <div class="filter">
8889 <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- 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')">
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)">
9291 </div>
9392 <div class="days">
9493 <section aria-label="2026-03-01">
@@ -259,7 +258,7 @@
259258 <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()">
260259 <div class="panel-head">
261260 <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)">
263262 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
264263 <kbd>esc</kbd>
265264 </button>
@@ -286,13 +285,7 @@
286285 <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()">
287286 <div class="panel-head">
288287 <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();
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() }">
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)">
296289 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
297290 <kbd>esc</kbd>
298291 </button>
@@ -357,67 +350,27 @@
357350 <kbd>esc</kbd> close</span>
358351 </p>
359352 </dialog>
360-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
361- if (q('dialog[open]').count) return;
362- let rows = q('[data-rows] > :not([hidden])').arr();
363- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
364- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
365- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
366- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
367- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
368- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
369- })(1);
370- k-k from:body -> (async (d) => {
371- if (q('dialog[open]').count) return;
372- let rows = q('[data-rows] > :not([hidden])').arr();
373- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
374- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
375- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
376- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
377- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
378- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
379- })(-1);
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));
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- }">
353+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
354+ k-k from:body -> ermite.move(-1);
355+ k-top from:body -> ermite.edge(false);
356+ k-bottom from:body -> ermite.edge(true);
357+ k-open from:body -> ermite.open();
358+ k-esc from:body -> ermite.closeDialogs();
359+ k-help from:body -> ermite.dialog('panel-help');
360+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
361+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
362+ k-filter from:body -> ermite.focusFilter();
363+ k-gh from:body -> ermite.go('.brand');
364+ k-s from:body -> ermite.tab('s');
365+ k-t from:body -> ermite.tab('t');
366+ k-c from:body -> ermite.tab('c');
367+ k-r from:body -> ermite.tab('r');
368+ k-m from:body -> ermite.tab('m');
369+ k-next from:body -> ermite.nextNote();
370+ k-split from:body -> ermite.layout('split');
371+ k-unified from:body -> ermite.layout('unified');
372+ k-collapse from:body -> ermite.collapse();
373+ k-permalink from:body -> ermite.permalink()">
421374 </div>
422375 </body>
423376 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -89,9 +90,7 @@
8990 <main id="main" tabindex="-1">
9091 <div class="filter">
9192 <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- 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')">
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)">
9594 </div>
9695 <p class="lede dim">history of <code>a.txt</code> at main</p>
9796 <div class="days">
@@ -274,7 +273,7 @@
274273 <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()">
275274 <div class="panel-head">
276275 <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)">
278277 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
279278 <kbd>esc</kbd>
280279 </button>
@@ -301,13 +300,7 @@
301300 <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()">
302301 <div class="panel-head">
303302 <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();
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() }">
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)">
311304 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
312305 <kbd>esc</kbd>
313306 </button>
@@ -372,67 +365,27 @@
372365 <kbd>esc</kbd> close</span>
373366 </p>
374367 </dialog>
375-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
376- if (q('dialog[open]').count) return;
377- let rows = q('[data-rows] > :not([hidden])').arr();
378- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
379- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
380- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
381- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
382- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
383- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
384- })(1);
385- k-k from:body -> (async (d) => {
386- if (q('dialog[open]').count) return;
387- let rows = q('[data-rows] > :not([hidden])').arr();
388- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
389- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
390- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
391- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
392- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
393- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
394- })(-1);
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));
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- }">
368+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
369+ k-k from:body -> ermite.move(-1);
370+ k-top from:body -> ermite.edge(false);
371+ k-bottom from:body -> ermite.edge(true);
372+ k-open from:body -> ermite.open();
373+ k-esc from:body -> ermite.closeDialogs();
374+ k-help from:body -> ermite.dialog('panel-help');
375+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
376+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
377+ k-filter from:body -> ermite.focusFilter();
378+ k-gh from:body -> ermite.go('.brand');
379+ k-s from:body -> ermite.tab('s');
380+ k-t from:body -> ermite.tab('t');
381+ k-c from:body -> ermite.tab('c');
382+ k-r from:body -> ermite.tab('r');
383+ k-m from:body -> ermite.tab('m');
384+ k-next from:body -> ermite.nextNote();
385+ k-split from:body -> ermite.layout('split');
386+ k-unified from:body -> ermite.layout('unified');
387+ k-collapse from:body -> ermite.collapse();
388+ k-permalink from:body -> ermite.permalink()">
436389 </div>
437390 </body>
438391 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">main</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -86,11 +87,9 @@
8687 <main id="main" tabindex="-1">
8788 <div class="filter">
8889 <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- 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')">
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)">
9291 </div>
93-<div id="commits-head">
92+<div id="commits-head" class="live-slot" role="status" data-slot>
9493 </div>
9594 <div class="days">
9695 <section aria-label="2026-03-26">
@@ -958,6 +957,8 @@
958957 </span>
959958 <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';
960959 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);
961962 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
962963 gap -> ermiteLive.reload()">
963964 </span>
@@ -1061,7 +1062,7 @@
10611062 <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()">
10621063 <div class="panel-head">
10631064 <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)">
10651066 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
10661067 <kbd>esc</kbd>
10671068 </button>
@@ -1088,13 +1089,7 @@
10881089 <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()">
10891090 <div class="panel-head">
10901091 <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- 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() }">
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)">
10981093 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
10991094 <kbd>esc</kbd>
11001095 </button>
@@ -1159,67 +1154,27 @@
11591154 <kbd>esc</kbd> close</span>
11601155 </p>
11611156 </dialog>
1162-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
1163- if (q('dialog[open]').count) return;
1164- let rows = q('[data-rows] > :not([hidden])').arr();
1165- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1166- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1167- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1168- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
1169- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
1170- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
1171- })(1);
1172- k-k from:body -> (async (d) => {
1173- if (q('dialog[open]').count) return;
1174- let rows = q('[data-rows] > :not([hidden])').arr();
1175- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1176- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1177- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1178- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
1179- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
1180- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
1181- })(-1);
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));
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- }">
1157+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
1158+ k-k from:body -> ermite.move(-1);
1159+ k-top from:body -> ermite.edge(false);
1160+ k-bottom from:body -> ermite.edge(true);
1161+ k-open from:body -> ermite.open();
1162+ k-esc from:body -> ermite.closeDialogs();
1163+ k-help from:body -> ermite.dialog('panel-help');
1164+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
1165+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
1166+ k-filter from:body -> ermite.focusFilter();
1167+ k-gh from:body -> ermite.go('.brand');
1168+ k-s from:body -> ermite.tab('s');
1169+ k-t from:body -> ermite.tab('t');
1170+ k-c from:body -> ermite.tab('c');
1171+ k-r from:body -> ermite.tab('r');
1172+ k-m from:body -> ermite.tab('m');
1173+ k-next from:body -> ermite.nextNote();
1174+ k-split from:body -> ermite.layout('split');
1175+ k-unified from:body -> ermite.layout('unified');
1176+ k-collapse from:body -> ermite.collapse();
1177+ k-permalink from:body -> ermite.permalink()">
12231178 </div>
12241179 </body>
12251180 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">main</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -86,11 +87,9 @@
8687 <main id="main" tabindex="-1">
8788 <div class="filter">
8889 <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- 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')">
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)">
9291 </div>
93-<div id="commits-head">
92+<div id="commits-head" class="live-slot" role="status" data-slot>
9493 </div>
9594 <div class="days">
9695 <section aria-label="2026-02-08">
@@ -303,6 +302,8 @@
303302 </span>
304303 <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';
305304 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);
306307 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
307308 gap -> ermiteLive.reload()">
308309 </span>
@@ -406,7 +407,7 @@
406407 <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()">
407408 <div class="panel-head">
408409 <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)">
410411 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
411412 <kbd>esc</kbd>
412413 </button>
@@ -441,13 +442,7 @@
441442 <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()">
442443 <div class="panel-head">
443444 <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- 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() }">
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)">
451446 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
452447 <kbd>esc</kbd>
453448 </button>
@@ -524,67 +519,27 @@
524519 <kbd>esc</kbd> close</span>
525520 </p>
526521 </dialog>
527-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
528- if (q('dialog[open]').count) return;
529- let rows = q('[data-rows] > :not([hidden])').arr();
530- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
531- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
532- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
533- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
534- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
535- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
536- })(1);
537- k-k from:body -> (async (d) => {
538- if (q('dialog[open]').count) return;
539- let rows = q('[data-rows] > :not([hidden])').arr();
540- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
541- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
542- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
543- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
544- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
545- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
546- })(-1);
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));
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- }">
522+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
523+ k-k from:body -> ermite.move(-1);
524+ k-top from:body -> ermite.edge(false);
525+ k-bottom from:body -> ermite.edge(true);
526+ k-open from:body -> ermite.open();
527+ k-esc from:body -> ermite.closeDialogs();
528+ k-help from:body -> ermite.dialog('panel-help');
529+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
530+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
531+ k-filter from:body -> ermite.focusFilter();
532+ k-gh from:body -> ermite.go('.brand');
533+ k-s from:body -> ermite.tab('s');
534+ k-t from:body -> ermite.tab('t');
535+ k-c from:body -> ermite.tab('c');
536+ k-r from:body -> ermite.tab('r');
537+ k-m from:body -> ermite.tab('m');
538+ k-next from:body -> ermite.nextNote();
539+ k-split from:body -> ermite.layout('split');
540+ k-unified from:body -> ermite.layout('unified');
541+ k-collapse from:body -> ermite.collapse();
542+ k-permalink from:body -> ermite.permalink()">
588543 </div>
589544 </body>
590545 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -39,19 +40,19 @@
3940 </ol>
4041 </nav>
4142 <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)">
4344 <span class="ico i-branch" aria-hidden="true">
4445 </span>
4546 <span class="ref-name">main</span>
4647 <span class="sha">SHA7</span>
47-</button>
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'))">
48+</a>
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">
4950 <span class="ico i-search" aria-hidden="true">
5051 </span>
5152 <span class="search-label">Go to…</span>
5253 <kbd aria-hidden="true">ctrl k</kbd>
5354 </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>
5556 </div>
5657 </div>
5758 <nav class="tabs" aria-label="Sections">
@@ -83,8 +84,7 @@
8384 <main id="main" tabindex="-1">
8485 <div class="filter">
8586 <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- let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')">
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)">
8888 </div>
8989 <table class="list refs" aria-label="Branches and tags">
9090 <thead>
@@ -194,6 +194,8 @@
194194 </span>
195195 <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';
196196 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);
197199 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
198200 gap -> ermiteLive.reload()">
199201 </span>
@@ -297,7 +299,7 @@
297299 <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()">
298300 <div class="panel-head">
299301 <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)">
301303 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
302304 <kbd>esc</kbd>
303305 </button>
@@ -336,13 +338,7 @@
336338 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
337339 <div class="panel-head">
338340 <label for="cmd-q" class="sr">go to</label>
339-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
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() }">
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)">
346342 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
347343 <kbd>esc</kbd>
348344 </button>
@@ -425,67 +421,27 @@
425421 <kbd>esc</kbd> close</span>
426422 </p>
427423 </dialog>
428-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
429- if (q('dialog[open]').count) return;
430- let rows = q('[data-rows] > :not([hidden])').arr();
431- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
432- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
433- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
434- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
435- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
436- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
437- })(1);
438- k-k from:body -> (async (d) => {
439- if (q('dialog[open]').count) return;
440- let rows = q('[data-rows] > :not([hidden])').arr();
441- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
442- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
443- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
444- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
445- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
446- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
447- })(-1);
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));
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- }">
424+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
425+ k-k from:body -> ermite.move(-1);
426+ k-top from:body -> ermite.edge(false);
427+ k-bottom from:body -> ermite.edge(true);
428+ k-open from:body -> ermite.open();
429+ k-esc from:body -> ermite.closeDialogs();
430+ k-help from:body -> ermite.dialog('panel-help');
431+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
432+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
433+ k-filter from:body -> ermite.focusFilter();
434+ k-gh from:body -> ermite.go('.brand');
435+ k-s from:body -> ermite.tab('s');
436+ k-t from:body -> ermite.tab('t');
437+ k-c from:body -> ermite.tab('c');
438+ k-r from:body -> ermite.tab('r');
439+ k-m from:body -> ermite.tab('m');
440+ k-next from:body -> ermite.nextNote();
441+ k-split from:body -> ermite.layout('split');
442+ k-unified from:body -> ermite.layout('unified');
443+ k-collapse from:body -> ermite.collapse();
444+ k-permalink from:body -> ermite.permalink()">
489445 </div>
490446 </body>
491447 </html>
R cmd/ermite/testdata/web/summary.html → internal/app/testdata/web/summary.html +32 -75 renamed, 62% similar
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -36,19 +37,19 @@
3637 </ol>
3738 </nav>
3839 <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)">
4041 <span class="ico i-branch" aria-hidden="true">
4142 </span>
4243 <span class="ref-name">main</span>
4344 <span class="sha">SHA7</span>
44-</button>
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'))">
45+</a>
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">
4647 <span class="ico i-search" aria-hidden="true">
4748 </span>
4849 <span class="search-label">Go to…</span>
4950 <kbd aria-hidden="true">ctrl k</kbd>
5051 </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>
5253 </div>
5354 </div>
5455 <nav class="tabs" aria-label="Sections">
@@ -85,7 +86,7 @@
8586 <h2 class="card-h" id="readme-h">
8687 <span class="ico i-file" aria-hidden="true">
8788 </span> README.md</h2>
88-<article class="prose" aria-label="README.md">
89+<article class="prose" aria-label="README.md" hx-ignore>
8990 <h1 id="md-demo">Demo</h1>
9091 <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>
9192 <h2 id="md-install">Install</h2>
@@ -118,7 +119,7 @@
118119 <div class="clone-line">
119120 <span class="mute" aria-hidden="true">$ </span>
120121 <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>
122123 </div>
123124 </div>
124125 </section>
@@ -212,6 +213,8 @@
212213 </span>
213214 <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';
214215 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);
215218 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
216219 gap -> ermiteLive.reload()">
217220 </span>
@@ -315,7 +318,7 @@
315318 <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()">
316319 <div class="panel-head">
317320 <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)">
319322 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
320323 <kbd>esc</kbd>
321324 </button>
@@ -354,13 +357,7 @@
354357 <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()">
355358 <div class="panel-head">
356359 <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();
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() }">
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)">
364361 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
365362 <kbd>esc</kbd>
366363 </button>
@@ -443,67 +440,27 @@
443440 <kbd>esc</kbd> close</span>
444441 </p>
445442 </dialog>
446-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
447- if (q('dialog[open]').count) return;
448- let rows = q('[data-rows] > :not([hidden])').arr();
449- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
450- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
451- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
452- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
453- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
454- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
455- })(1);
456- k-k from:body -> (async (d) => {
457- if (q('dialog[open]').count) return;
458- let rows = q('[data-rows] > :not([hidden])').arr();
459- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
460- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
461- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
462- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
463- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
464- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
465- })(-1);
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));
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- }">
443+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
444+ k-k from:body -> ermite.move(-1);
445+ k-top from:body -> ermite.edge(false);
446+ k-bottom from:body -> ermite.edge(true);
447+ k-open from:body -> ermite.open();
448+ k-esc from:body -> ermite.closeDialogs();
449+ k-help from:body -> ermite.dialog('panel-help');
450+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
451+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
452+ k-filter from:body -> ermite.focusFilter();
453+ k-gh from:body -> ermite.go('.brand');
454+ k-s from:body -> ermite.tab('s');
455+ k-t from:body -> ermite.tab('t');
456+ k-c from:body -> ermite.tab('c');
457+ k-r from:body -> ermite.tab('r');
458+ k-m from:body -> ermite.tab('m');
459+ k-next from:body -> ermite.nextNote();
460+ k-split from:body -> ermite.layout('split');
461+ k-unified from:body -> ermite.layout('unified');
462+ k-collapse from:body -> ermite.collapse();
463+ k-permalink from:body -> ermite.permalink()">
507464 </div>
508465 </body>
509466 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -48,19 +49,19 @@
4849 </ol>
4950 </nav>
5051 <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)">
5253 <span class="ico i-branch" aria-hidden="true">
5354 </span>
5455 <span class="ref-name">main</span>
5556 <span class="sha">SHA7</span>
56-</button>
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'))">
57+</a>
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">
5859 <span class="ico i-search" aria-hidden="true">
5960 </span>
6061 <span class="search-label">Go to…</span>
6162 <kbd aria-hidden="true">ctrl k</kbd>
6263 </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>
6465 </div>
6566 </div>
6667 <nav class="tabs" aria-label="Sections">
@@ -250,7 +251,7 @@
250251 <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()">
251252 <div class="panel-head">
252253 <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)">
254255 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
255256 <kbd>esc</kbd>
256257 </button>
@@ -285,13 +286,7 @@
285286 <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()">
286287 <div class="panel-head">
287288 <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- 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() }">
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)">
295290 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
296291 <kbd>esc</kbd>
297292 </button>
@@ -362,67 +357,27 @@
362357 <kbd>esc</kbd> close</span>
363358 </p>
364359 </dialog>
365-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
366- if (q('dialog[open]').count) return;
367- let rows = q('[data-rows] > :not([hidden])').arr();
368- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
369- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
370- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
371- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
372- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
373- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
374- })(1);
375- k-k from:body -> (async (d) => {
376- if (q('dialog[open]').count) return;
377- let rows = q('[data-rows] > :not([hidden])').arr();
378- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
379- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
380- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
381- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
382- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
383- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
384- })(-1);
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));
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- }">
360+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
361+ k-k from:body -> ermite.move(-1);
362+ k-top from:body -> ermite.edge(false);
363+ k-bottom from:body -> ermite.edge(true);
364+ k-open from:body -> ermite.open();
365+ k-esc from:body -> ermite.closeDialogs();
366+ k-help from:body -> ermite.dialog('panel-help');
367+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
368+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
369+ k-filter from:body -> ermite.focusFilter();
370+ k-gh from:body -> ermite.go('.brand');
371+ k-s from:body -> ermite.tab('s');
372+ k-t from:body -> ermite.tab('t');
373+ k-c from:body -> ermite.tab('c');
374+ k-r from:body -> ermite.tab('r');
375+ k-m from:body -> ermite.tab('m');
376+ k-next from:body -> ermite.nextNote();
377+ k-split from:body -> ermite.layout('split');
378+ k-unified from:body -> ermite.layout('unified');
379+ k-collapse from:body -> ermite.collapse();
380+ k-permalink from:body -> ermite.permalink()">
426381 </div>
427382 </body>
428383 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -42,19 +43,19 @@
4243 </ol>
4344 </nav>
4445 <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)">
4647 <span class="ico i-branch" aria-hidden="true">
4748 </span>
4849 <span class="ref-name">main</span>
4950 <span class="sha">SHA7</span>
50-</button>
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'))">
51+</a>
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">
5253 <span class="ico i-search" aria-hidden="true">
5354 </span>
5455 <span class="search-label">Go to…</span>
5556 <kbd aria-hidden="true">ctrl k</kbd>
5657 </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>
5859 </div>
5960 </div>
6061 <nav class="tabs" aria-label="Sections">
@@ -356,7 +357,7 @@
356357 <h2 class="card-h" id="readme-h">
357358 <span class="ico i-file" aria-hidden="true">
358359 </span> README.md</h2>
359-<article class="prose" aria-label="README.md">
360+<article class="prose" aria-label="README.md" hx-ignore>
360361 <h1 id="md-files">Files</h1>
361362 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
362363 </article>
@@ -477,7 +478,7 @@
477478 <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()">
478479 <div class="panel-head">
479480 <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)">
481482 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
482483 <kbd>esc</kbd>
483484 </button>
@@ -512,13 +513,7 @@
512513 <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()">
513514 <div class="panel-head">
514515 <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- 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() }">
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)">
522517 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
523518 <kbd>esc</kbd>
524519 </button>
@@ -589,67 +584,27 @@
589584 <kbd>esc</kbd> close</span>
590585 </p>
591586 </dialog>
592-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
593- if (q('dialog[open]').count) return;
594- let rows = q('[data-rows] > :not([hidden])').arr();
595- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
596- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
597- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
598- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
599- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
600- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
601- })(1);
602- k-k from:body -> (async (d) => {
603- if (q('dialog[open]').count) return;
604- let rows = q('[data-rows] > :not([hidden])').arr();
605- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
606- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
607- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
608- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
609- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
610- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
611- })(-1);
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));
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- }">
587+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
588+ k-k from:body -> ermite.move(-1);
589+ k-top from:body -> ermite.edge(false);
590+ k-bottom from:body -> ermite.edge(true);
591+ k-open from:body -> ermite.open();
592+ k-esc from:body -> ermite.closeDialogs();
593+ k-help from:body -> ermite.dialog('panel-help');
594+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
595+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
596+ k-filter from:body -> ermite.focusFilter();
597+ k-gh from:body -> ermite.go('.brand');
598+ k-s from:body -> ermite.tab('s');
599+ k-t from:body -> ermite.tab('t');
600+ k-c from:body -> ermite.tab('c');
601+ k-r from:body -> ermite.tab('r');
602+ k-m from:body -> ermite.tab('m');
603+ k-next from:body -> ermite.nextNote();
604+ k-split from:body -> ermite.layout('split');
605+ k-unified from:body -> ermite.layout('unified');
606+ k-collapse from:body -> ermite.collapse();
607+ k-permalink from:body -> ermite.permalink()">
653608 </div>
654609 </body>
655610 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">feature/x</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -247,7 +248,7 @@
247248 <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()">
248249 <div class="panel-head">
249250 <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)">
251252 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
252253 <kbd>esc</kbd>
253254 </button>
@@ -282,13 +283,7 @@
282283 <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()">
283284 <div class="panel-head">
284285 <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- 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() }">
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)">
292287 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
293288 <kbd>esc</kbd>
294289 </button>
@@ -359,67 +354,27 @@
359354 <kbd>esc</kbd> close</span>
360355 </p>
361356 </dialog>
362-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
363- if (q('dialog[open]').count) return;
364- let rows = q('[data-rows] > :not([hidden])').arr();
365- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
366- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
367- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
368- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
369- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
370- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
371- })(1);
372- k-k from:body -> (async (d) => {
373- if (q('dialog[open]').count) return;
374- let rows = q('[data-rows] > :not([hidden])').arr();
375- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
376- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
377- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
378- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
379- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
380- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
381- })(-1);
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));
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- }">
357+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
358+ k-k from:body -> ermite.move(-1);
359+ k-top from:body -> ermite.edge(false);
360+ k-bottom from:body -> ermite.edge(true);
361+ k-open from:body -> ermite.open();
362+ k-esc from:body -> ermite.closeDialogs();
363+ k-help from:body -> ermite.dialog('panel-help');
364+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
365+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
366+ k-filter from:body -> ermite.focusFilter();
367+ k-gh from:body -> ermite.go('.brand');
368+ k-s from:body -> ermite.tab('s');
369+ k-t from:body -> ermite.tab('t');
370+ k-c from:body -> ermite.tab('c');
371+ k-r from:body -> ermite.tab('r');
372+ k-m from:body -> ermite.tab('m');
373+ k-next from:body -> ermite.nextNote();
374+ k-split from:body -> ermite.layout('split');
375+ k-unified from:body -> ermite.layout('unified');
376+ k-collapse from:body -> ermite.collapse();
377+ k-permalink from:body -> ermite.permalink()">
423378 </div>
424379 </body>
425380 </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 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -45,19 +46,19 @@
4546 </ol>
4647 </nav>
4748 <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)">
4950 <span class="ico i-branch" aria-hidden="true">
5051 </span>
5152 <span class="ref-name">main</span>
5253 <span class="sha">SHA7</span>
53-</button>
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'))">
54+</a>
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">
5556 <span class="ico i-search" aria-hidden="true">
5657 </span>
5758 <span class="search-label">Go to…</span>
5859 <kbd aria-hidden="true">ctrl k</kbd>
5960 </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>
6162 </div>
6263 </div>
6364 <nav class="tabs" aria-label="Sections">
@@ -151,7 +152,7 @@
151152 <h2 class="card-h" id="readme-h">
152153 <span class="ico i-file" aria-hidden="true">
153154 </span> README.md</h2>
154-<article class="prose" aria-label="README.md">
155+<article class="prose" aria-label="README.md" hx-ignore>
155156 <h1 id="md-docs">Docs</h1>
156157 <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>
157158 </article>
@@ -272,7 +273,7 @@
272273 <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()">
273274 <div class="panel-head">
274275 <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)">
276277 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
277278 <kbd>esc</kbd>
278279 </button>
@@ -307,13 +308,7 @@
307308 <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()">
308309 <div class="panel-head">
309310 <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- 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() }">
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)">
317312 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
318313 <kbd>esc</kbd>
319314 </button>
@@ -384,67 +379,27 @@
384379 <kbd>esc</kbd> close</span>
385380 </p>
386381 </dialog>
387-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
388- if (q('dialog[open]').count) return;
389- let rows = q('[data-rows] > :not([hidden])').arr();
390- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
391- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
392- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
393- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
394- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
395- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
396- })(1);
397- k-k from:body -> (async (d) => {
398- if (q('dialog[open]').count) return;
399- let rows = q('[data-rows] > :not([hidden])').arr();
400- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
401- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
402- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
403- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
404- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
405- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
406- })(-1);
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));
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- }">
382+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
383+ k-k from:body -> ermite.move(-1);
384+ k-top from:body -> ermite.edge(false);
385+ k-bottom from:body -> ermite.edge(true);
386+ k-open from:body -> ermite.open();
387+ k-esc from:body -> ermite.closeDialogs();
388+ k-help from:body -> ermite.dialog('panel-help');
389+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
390+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();
391+ k-filter from:body -> ermite.focusFilter();
392+ k-gh from:body -> ermite.go('.brand');
393+ k-s from:body -> ermite.tab('s');
394+ k-t from:body -> ermite.tab('t');
395+ k-c from:body -> ermite.tab('c');
396+ k-r from:body -> ermite.tab('r');
397+ k-m from:body -> ermite.tab('m');
398+ k-next from:body -> ermite.nextNote();
399+ k-split from:body -> ermite.layout('split');
400+ k-unified from:body -> ermite.layout('unified');
401+ k-collapse from:body -> ermite.collapse();
402+ k-permalink from:body -> ermite.permalink()">
448403 </div>
449404 </body>
450405 </html>
R cmd/ermite/web.go → internal/app/web.go +45 -44 renamed, 56% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
@@ -13,40 +13,35 @@ import (
1313
1414 "github.com/bnema/zerowrap"
1515
16- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1716 "bnema.dev/ermite/internal/adapters/httpserver"
18- "bnema.dev/ermite/internal/adapters/rpc"
1917 "bnema.dev/ermite/internal/adapters/web"
20- "bnema.dev/ermite/internal/app"
2118 "bnema.dev/ermite/internal/clientlimit"
2219 "bnema.dev/ermite/internal/config"
2320 "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
2423 )
2524
26-// newWeb builds the web pages on the API of this process: the generated
27-// Connect clients are bound to the handlers of api, with no socket (ADR 0020).
25+// newWeb builds the web pages on the use cases of this process, through the
26+// ports (ADR 0020), like the API and Git over HTTP.
2827 //
2928 // The owner's session cookie turns a request into the owner's (web.Server
30-// checks it with sessions); every other request is anonymous.
31-func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) {
32- hc := api.InProcessClient()
33- srv, err := web.New(web.Config{
34- Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
35- Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
36- MergeRequests: ermiteconnect.NewMergeRequestServiceClient(hc, rpc.InProcessURL),
37- Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL),
29+// checks it with sessions); every other request is anonymous. The web passes
30+// the principal of the request to each use case call itself.
31+func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {
32+ return web.New(web.Config{
33+ Repos: f.Repos,
34+ Browse: f.Browse,
35+ MergeRequests: f.MergeRequests,
36+ Events: f.EventLog,
3837 PublicURL: cfg.PublicURL,
3938 SourceURL: cfg.SourceURL,
4039 Version: serverVersion(),
4140
42- Sessions: webSessions{sessions},
41+ Sessions: webSessions{f.Sessions},
4342 Limiter: limiter,
4443 TrustedProxies: cfg.TrustedProxies,
4544 })
46- if err != nil {
47- return nil, err
48- }
49- return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil
5045 }
5146
5247 // sessionsNeedHTTPS reports whether browser sign-in cannot work on this public
@@ -79,15 +74,34 @@ func warnSessionsNeedHTTPS(ctx context.Context, publicURL string) {
7974 "and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https")
8075 }
8176
82-// webSessions adapts the session use cases to web.SessionAuth: a valid token
83-// gives the context of the owner, whose principal the API calls of the page
84-// carry (rpc.API.InProcessClient).
85-type webSessions struct{ *app.Sessions }
77+// warnSharedClientKey tells the operator, at startup, that without trusted
78+// proxies every request is attributed to the address of its TCP peer. Behind a
79+// reverse proxy that is the proxy: all the clients then share one key, and so
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{}
86100
87101 func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) {
88102 token, expiresAt, err := s.Sessions.Login(ctx, nonce)
89103 if errors.Is(err, domain.ErrUnauthenticated) {
90- return "", 0, web.ErrLinkRejected
104+ return "", 0, ports.ErrLinkRejected
91105 }
92106 if err != nil {
93107 return "", 0, err
@@ -96,38 +110,25 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura
96110 // that made the link may cut short. The session token carries its own
97111 // expiry, so a cookie that outlives it by under a second is harmless.
98112 // The expiry is truncated to the second, hence the slack on the full TTL.
99- lifetime := app.SessionTTL
100- if d := time.Until(expiresAt); d < app.SessionTTL-time.Second {
113+ lifetime := usecase.SessionTTL
114+ if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
101115 lifetime = d.Round(time.Second)
102116 }
103117 if lifetime < time.Second {
104- return "", 0, web.ErrLinkRejected
118+ return "", 0, ports.ErrLinkRejected
105119 }
106120 return token, lifetime, nil
107121 }
108122
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) {
110124 p, err := s.Sessions.Verify(ctx, token)
111125 if errors.Is(err, domain.ErrUnauthenticated) {
112- return ctx, false, nil
126+ return domain.Anonymous(), false, nil
113127 }
114128 if err != nil {
115- return ctx, false, err
129+ return domain.Anonymous(), false, err
116130 }
117- return domain.ContextWithPrincipal(ctx, 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+ return p, true, nil
131132 }
132133
133134 // 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/web_commits_test.go → internal/app/web_commits_test.go +3 -3 renamed, 97% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
@@ -14,8 +14,8 @@ import (
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/require"
1616
17- "bnema.dev/ermite/internal/app"
1817 "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
1919 )
2020
2121 // diffFixture is the repository of the commit pages: one commit for each
@@ -27,7 +27,7 @@ type diffFixture struct {
2727 // createRepo creates a public repository for the pages.
2828 func (h *harness) createRepo(name string, vis domain.Visibility) {
2929 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})
3131 require.NoError(h.t, err)
3232 }
3333
R cmd/ermite/web_events_test.go → internal/app/web_events_test.go +10 -9 renamed, 94% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
@@ -15,8 +15,9 @@ import (
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/require"
1717
18- "bnema.dev/ermite/internal/app"
1918 "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
2021 )
2122
2223 // sseEvent is one event read from /-/events.
@@ -126,7 +127,7 @@ func TestWebEventsHome(t *testing.T) {
126127 assert.Contains(t, ev.data, `href="/pub"`)
127128
128129 // 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})
130131 require.NoError(t, err)
131132 ev = s.next(t, watchWait)
132133 assert.Equal(t, "home-list", ev.name)
@@ -136,7 +137,7 @@ func TestWebEventsHome(t *testing.T) {
136137
137138 // A repository that turns private is removed from the anonymous list.
138139 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})
140141 require.NoError(t, err)
141142 ev = s.next(t, watchWait)
142143 assert.Equal(t, "home-list", ev.name)
@@ -162,7 +163,7 @@ func TestWebEventsRepoFragments(t *testing.T) {
162163 refs := h.sse("topic=repo:x&view=refs", "")
163164
164165 _, _, 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")
166167 m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page)
167168 require.Len(t, m, 2, "the commits page is live")
168169 url := html.UnescapeString(m[1])
@@ -286,7 +287,7 @@ func TestWebEventsPrivate(t *testing.T) {
286287 wt := h.workTree("main", "a.txt")
287288 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
288289 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})
290291 require.NoError(t, err)
291292 // Made public later, it does not reveal what happened while it was
292293 // private; the update that makes it public is shown.
@@ -303,7 +304,7 @@ func TestWebEventsPrivate(t *testing.T) {
303304 // public repository and requires its row to be the next event.
304305 func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) {
305306 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})
307308 require.NoError(t, err)
308309 ev := s.next(t, watchWait)
309310 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) {
380381 // one address cannot take every watch slot of the forge.
381382 func TestWebEventsLimitPerVisitor(t *testing.T) {
382383 h := newHarness(t, harnessOpts{})
383- for range app.MaxWatchersPerClient {
384+ for range usecase.MaxWatchersPerClient {
384385 h.sse("topic=home", "")
385386 }
386387 req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil)
@@ -390,7 +391,7 @@ func TestWebEventsLimitPerVisitor(t *testing.T) {
390391 defer func() { _ = resp.Body.Close() }()
391392 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
392393 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())
394395 }
395396
396397 // 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
@@ -22,8 +22,8 @@ import (
2222 "github.com/stretchr/testify/assert"
2323 "github.com/stretchr/testify/require"
2424
25- "bnema.dev/ermite/internal/app"
2625 "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
2727 )
2828
2929 // Trojan Source: a right-to-left override and an isolate around code.
@@ -43,7 +43,7 @@ func tinyPNG(t *testing.T) []byte {
4343 // tag with a slash.
4444 func (h *harness) webFilesRepo(name string) {
4545 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{
4747 Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic,
4848 })
4949 require.NoError(h.t, err)
@@ -152,7 +152,7 @@ func TestWebTreeRows(t *testing.T) {
152152 // that links to the directory page, which says the listing is cut.
153153 func TestWebTreeRowsTruncated(t *testing.T) {
154154 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})
156156 require.NoError(t, err)
157157 wt := h.t.TempDir()
158158 h.mustGit(wt, "init", "-q", "--initial-branch=main")
@@ -396,7 +396,7 @@ func TestWebFilesContentSecurity(t *testing.T) {
396396 h := newHarness(t, harnessOpts{})
397397 h.webFilesRepo("files")
398398
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'"
400400 for _, p := range []string{
401401 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
402402 "/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) {
529529 // anonymous gets the 404 of a missing repository, as the owner still
530530 // reads it.
531531 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})
533533 require.NoError(t, err)
534534 for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} {
535535 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "html"
@@ -13,8 +13,8 @@ import (
1313 "github.com/stretchr/testify/assert"
1414 "github.com/stretchr/testify/require"
1515
16- "bnema.dev/ermite/internal/app"
1716 "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
1818 )
1919
2020 // mrRepo is a public repository "proj" with README.md on main and a branch
@@ -67,7 +67,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
6767 anon := b.get("/proj/compare/feat/search", nil)
6868 require.Equal(t, 200, anon.status, anon.body)
6969 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"))
7171 assert.Contains(t, anon.body, "feat/search")
7272 assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit")
7373 assert.NotContains(t, anon.body, "<form class=\"cform create\"")
@@ -129,7 +129,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
129129 mr := b.get("/proj/merge-requests/1", nil)
130130 require.Equal(t, 200, mr.status, mr.body)
131131 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"))
133133 assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
134134 assert.NotContains(t, mr.body, "<b>search</b>")
135135 assert.Contains(t, mr.body, "feat/search")
@@ -466,7 +466,7 @@ func TestWebMergeRequestLimitIsFriendly(t *testing.T) {
466466 token := r.signIn(t)
467467 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
468468 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"})
470470 require.NoError(t, err)
471471 }
472472 full := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"one more"}})
@@ -511,7 +511,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
511511 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
512512 ev := list.next(t, watchWait)
513513 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>`)
515515 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
516516 assert.NotContains(t, ev.data, "Secret")
517517
R cmd/ermite/web_session_test.go → internal/app/web_session_test.go +49 -11 renamed, 92% similar
@@ -1,11 +1,12 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
77 "io"
88 "net/http"
9+ "net/netip"
910 "net/url"
1011 "strings"
1112 "testing"
@@ -18,8 +19,8 @@ import (
1819 "github.com/stretchr/testify/require"
1920
2021 pb "bnema.dev/ermite-api/gen/ermite"
21- "bnema.dev/ermite/internal/app"
2222 "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
2324 )
2425
2526 const (
@@ -115,7 +116,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
115116 assert.Contains(t, home.body, "pub")
116117 assert.NotContains(t, home.body, "secret")
117118 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")
119120 assert.NotContains(t, home.body, `action="/-/logout"`)
120121 assert.Equal(t, 404, b.get("/secret", nil).status)
121122
@@ -310,7 +311,7 @@ func TestWebRevokingAnOwnerTokenSignsEveryoneOut(t *testing.T) {
310311
311312 // The cap: a link made by a token that expires in an hour gives a cookie,
312313 // 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{
314315 Label: "soon", Scopes: domain.AllScopes(), ExpiresAt: time.Now().Add(time.Hour),
315316 })
316317 require.NoError(t, err)
@@ -505,14 +506,22 @@ func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) {
505506 assert.NotContains(t, logs, string(key))
506507 }
507508
508-// The owner principal is what the API sees from a signed-in page, and is
509-// labelled "owner".
509+// The owner principal is what the use cases see from a signed-in page, and is
510+// labelled "owner": the use case logs it with a write the page made.
510511 func TestWebOwnerIsLabelledInLogs(t *testing.T) {
511- c := newCLIHarness(t)
512- c.seed("secret", domain.VisibilityPrivate, "notes.txt")
513- token := c.signIn(t)
514- browser{c.harness}.get("/secret", as(token))
515- assert.Contains(t, c.logs.String(), `"principal":"owner"`)
512+ r := newMRRepo(t, domain.VisibilityPublic)
513+ token := r.signIn(t)
514+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
515+ b := browser{r.harness}
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"`)
516525 }
517526
518527 // GO-004: a Secure cookie is kept by browsers only from https (or a loopback
@@ -558,3 +567,32 @@ func TestStartupWarnsWhenSignInCannotWork(t *testing.T) {
558567 warnSessionsNeedHTTPS(ctx, "http://forge.example")
559568 assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL")
560569 }
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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "flag"
@@ -16,8 +16,8 @@ import (
1616 "github.com/stretchr/testify/assert"
1717 "github.com/stretchr/testify/require"
1818
19- "bnema.dev/ermite/internal/app"
2019 "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
2121 )
2222
2323 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)
3030 // branches (one with a slash), two tags and a description.
3131 func (h *harness) webRepo(name string) {
3232 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{
3434 Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic,
3535 })
3636 require.NoError(h.t, err)
@@ -100,7 +100,7 @@ func golden(t *testing.T, name, got string) {
100100 return
101101 }
102102 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")
104104 assert.Equal(t, string(want), got, name)
105105 }
106106
@@ -162,7 +162,7 @@ func TestWebPrivateRepoIsInvisible(t *testing.T) {
162162 // AC4, end to end: a README that attacks the page is neutralised.
163163 func TestWebReadmeIsNeutralised(t *testing.T) {
164164 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})
166166 require.NoError(t, err)
167167 wt := h.t.TempDir()
168168 h.mustGit(wt, "init", "-q", "--initial-branch=main")
@@ -207,7 +207,7 @@ func TestWebContentSecurity(t *testing.T) {
207207 h.webRepo("demo")
208208 h.seed("quiet", domain.VisibilityPublic)
209209
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'"
211211 for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
212212 status, hdr, body := h.get(p)
213213 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 (
1616 "time"
1717
1818 "github.com/bnema/zerowrap"
19+
20+ "bnema.dev/ermite/internal/ports"
1921 )
2022
2123 // Limiter defaults: 10 failed authentications in 10 minutes block the client
@@ -54,6 +56,8 @@ type Limiter struct {
5456 lastSweep time.Time
5557 }
5658
59+var _ ports.FailureLimiter = (*Limiter)(nil)
60+
5761 // NewLimiter builds a limiter. now defaults to time.Now.
5862 func NewLimiter(now func() time.Time) *Limiter {
5963 if now == nil {
M internal/clientlimit/limiter_test.go +9 -0
@@ -23,6 +23,7 @@ type fakeClock struct{ t time.Time }
2323 func (c *fakeClock) now() time.Time { return c.t }
2424
2525 func TestLimiter(t *testing.T) {
26+ t.Parallel()
2627 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
2728 l := clientlimit.NewLimiter(clk.now)
2829
@@ -52,6 +53,7 @@ func TestLimiter(t *testing.T) {
5253 }
5354
5455 func TestLimiterSlidingWindow(t *testing.T) {
56+ t.Parallel()
5557 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
5658 l := clientlimit.NewLimiter(clk.now)
5759 for range 5 {
@@ -71,6 +73,7 @@ func TestLimiterSlidingWindow(t *testing.T) {
7173 }
7274
7375 func TestLimiterOneAddressStaysSmall(t *testing.T) {
76+ t.Parallel()
7477 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
7578 l := clientlimit.NewLimiter(clk.now)
7679 for range 5000 {
@@ -82,6 +85,7 @@ func TestLimiterOneAddressStaysSmall(t *testing.T) {
8285 }
8386
8487 func TestLimiterBoundsMemory(t *testing.T) {
88+ t.Parallel()
8589 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
8690 l := clientlimit.NewLimiter(clk.now)
8791 for i := range clientlimit.MaxTracked + 5000 {
@@ -103,6 +107,7 @@ func TestLimiterBoundsMemory(t *testing.T) {
103107 }
104108
105109 func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {
110+ t.Parallel()
106111 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
107112 l := clientlimit.NewLimiter(clk.now)
108113 for i := range 100 {
@@ -135,6 +140,7 @@ func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {
135140 }
136141
137142 func TestLimiterGroupsIPv6By64(t *testing.T) {
143+ t.Parallel()
138144 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
139145 l := clientlimit.NewLimiter(clk.now)
140146
@@ -155,6 +161,7 @@ func TestLimiterGroupsIPv6By64(t *testing.T) {
155161 }
156162
157163 func TestLimiterIPv4StaysPerAddress(t *testing.T) {
164+ t.Parallel()
158165 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
159166 l := clientlimit.NewLimiter(clk.now)
160167 for range clientlimit.MaxFailures {
@@ -170,6 +177,7 @@ func TestLimiterIPv4StaysPerAddress(t *testing.T) {
170177 }
171178
172179 func TestClientAddr(t *testing.T) {
180+ t.Parallel()
173181 trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}
174182 tests := []struct {
175183 name string
@@ -194,6 +202,7 @@ func TestClientAddr(t *testing.T) {
194202 }
195203 for _, tt := range tests {
196204 t.Run(tt.name, func(t *testing.T) {
205+ t.Parallel()
197206 r := &http.Request{RemoteAddr: tt.remote, Header: http.Header{}}
198207 for _, v := range tt.xff {
199208 r.Header.Add("X-Forwarded-For", v)
M internal/config/config.go +1 -1
@@ -1,7 +1,7 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
33 // Package config parses and validates the server configuration from the
4-// environment. It is an entrypoint helper: only cmd imports it, and it never
4+// environment. It is a composition-root helper: only internal/app imports it, and it never
55 // reads the process environment itself, the caller passes a lookup function.
66 package config
77
M internal/domain/errors.go +22 -0
@@ -37,3 +37,25 @@ var (
3737 // the number of event watchers; the caller may retry later.
3838 ErrResourceExhausted = errors.New("resource exhausted")
3939 )
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 (
1313 )
1414
1515 func TestNewRefUpdate(t *testing.T) {
16+ t.Parallel()
1617 a, b := strings.Repeat("a", 40), strings.Repeat("b", 40)
1718
1819 u, err := domain.NewRefUpdate(domain.ZeroOID, a, "refs/heads/main")
@@ -46,6 +47,7 @@ func TestNewRefUpdate(t *testing.T) {
4647 }
4748
4849 func TestEventKinds(t *testing.T) {
50+ t.Parallel()
4951 assert.Equal(t, "repo.created", domain.RepoCreated{}.EventKind())
5052 assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())
5153 assert.Equal(t, "gap", domain.EventGap{}.EventKind())
M internal/domain/git_test.go +6 -0
@@ -13,6 +13,7 @@ import (
1313 )
1414
1515 func TestParseObjectID(t *testing.T) {
16+ t.Parallel()
1617 good := strings.Repeat("ab12", 10)
1718 id, err := domain.ParseObjectID(good)
1819 require.NoError(t, err)
@@ -27,6 +28,7 @@ func TestParseObjectID(t *testing.T) {
2728 }
2829
2930 func TestParseRevision(t *testing.T) {
31+ t.Parallel()
3032 valid := []string{
3133 "", "main", "feature/x", "refs/heads/feature/x", "v1.0.0", "HEAD",
3234 "abc1234", strings.Repeat("a", 40), "release-2026.10", "café",
@@ -47,6 +49,7 @@ func TestParseRevision(t *testing.T) {
4749 }
4850
4951 func TestRevisionKinds(t *testing.T) {
52+ t.Parallel()
5053 head, _ := domain.ParseRevision("")
5154 assert.True(t, head.IsHead())
5255 full, _ := domain.ParseRevision(strings.Repeat("a", 40))
@@ -64,6 +67,7 @@ func TestRevisionKinds(t *testing.T) {
6467 }
6568
6669 func TestParseTreePath(t *testing.T) {
70+ t.Parallel()
6771 root, err := domain.ParseTreePath("")
6872 require.NoError(t, err)
6973 assert.True(t, root.IsRoot())
@@ -150,6 +154,7 @@ func TestPathListAdd(t *testing.T) {
150154 }
151155
152156 func TestEntryKindOf(t *testing.T) {
157+ t.Parallel()
153158 tests := map[uint32]domain.EntryKind{
154159 0o100644: domain.EntryFile,
155160 0o100664: domain.EntryFile,
@@ -166,5 +171,6 @@ func TestEntryKindOf(t *testing.T) {
166171 }
167172
168173 func TestDefaultDiffLimits(t *testing.T) {
174+ t.Parallel()
169175 assert.Equal(t, domain.DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}, domain.DefaultDiffLimits())
170176 }
M internal/domain/mergerequest_test.go +10 -0
@@ -21,6 +21,7 @@ func oid(t *testing.T, c byte) domain.ObjectID {
2121 }
2222
2323 func TestDefaultRepoSettings(t *testing.T) {
24+ t.Parallel()
2425 s := domain.DefaultRepoSettings()
2526 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
2627 assert.True(t, s.FailingChecksBlock)
@@ -35,6 +36,7 @@ func TestDefaultRepoSettings(t *testing.T) {
3536 }
3637
3738 func TestNormalizeMergeModes(t *testing.T) {
39+ t.Parallel()
3840 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
3941 require.NoError(t, err)
4042 assert.Equal(t, domain.MergeModes(), got)
@@ -50,6 +52,7 @@ func TestNormalizeMergeModes(t *testing.T) {
5052 }
5153
5254 func TestParseMergeMode(t *testing.T) {
55+ t.Parallel()
5356 m, err := domain.ParseMergeMode("fast-forward")
5457 require.NoError(t, err)
5558 assert.Equal(t, domain.MergeModeFastForward, m)
@@ -78,6 +81,7 @@ func hunkDiff() domain.Diff {
7881 }
7982
8083 func TestDiffHasLine(t *testing.T) {
84+ t.Parallel()
8185 d := hunkDiff()
8286 tests := []struct {
8387 name string
@@ -106,6 +110,7 @@ func TestDiffHasLine(t *testing.T) {
106110 }
107111 for _, tc := range tests {
108112 t.Run(tc.name, func(t *testing.T) {
113+ t.Parallel()
109114 assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line))
110115 })
111116 }
@@ -116,6 +121,7 @@ func sig(name, email string, sec int64) domain.Signature {
116121 }
117122
118123 func TestNewSquashCommit(t *testing.T) {
124+ t.Parallel()
119125 title, err := domain.ParseMRTitle("Add search")
120126 require.NoError(t, err)
121127 mr := domain.MergeRequest{Number: 7, Title: title}
@@ -151,6 +157,7 @@ func TestNewSquashCommit(t *testing.T) {
151157 }
152158
153159 func TestSquashCommitSigned(t *testing.T) {
160+ t.Parallel()
154161 title, err := domain.ParseMRTitle("T")
155162 require.NoError(t, err)
156163 c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'),
@@ -169,6 +176,7 @@ func TestSquashCommitSigned(t *testing.T) {
169176 }
170177
171178 func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) {
179+ t.Parallel()
172180 title, err := domain.ParseMRTitle("T")
173181 require.NoError(t, err)
174182 commits := []domain.Commit{
@@ -183,6 +191,7 @@ func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) {
183191 }
184192
185193 func TestSigningKeyInfoAllowedSigners(t *testing.T) {
194+ t.Parallel()
186195 i := domain.SigningKeyInfo{
187196 Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"},
188197 Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
@@ -191,6 +200,7 @@ func TestSigningKeyInfoAllowedSigners(t *testing.T) {
191200 }
192201
193202 func TestParseBranchName(t *testing.T) {
203+ t.Parallel()
194204 for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} {
195205 got, err := domain.ParseBranchName(ok)
196206 require.NoError(t, err, ok)
M internal/domain/name_test.go +6 -0
@@ -14,6 +14,7 @@ import (
1414 )
1515
1616 func TestParseRepoName(t *testing.T) {
17+ t.Parallel()
1718 tests := []struct {
1819 in string
1920 valid bool
@@ -45,6 +46,7 @@ func TestParseRepoName(t *testing.T) {
4546 }
4647 for _, tt := range tests {
4748 t.Run(tt.in, func(t *testing.T) {
49+ t.Parallel()
4850 n, err := domain.ParseRepoName(tt.in)
4951 if tt.valid {
5052 require.NoError(t, err)
@@ -66,6 +68,7 @@ func TestParseRepoName(t *testing.T) {
6668 }
6769
6870 func TestParseRepoPath(t *testing.T) {
71+ t.Parallel()
6972 tests := []struct {
7073 in string
7174 want string
@@ -80,6 +83,7 @@ func TestParseRepoPath(t *testing.T) {
8083 }
8184 for _, tt := range tests {
8285 t.Run(tt.in, func(t *testing.T) {
86+ t.Parallel()
8387 n, err := domain.ParseRepoPath(tt.in)
8488 if !tt.valid {
8589 assert.ErrorIs(t, err, domain.ErrInvalidName)
@@ -92,6 +96,7 @@ func TestParseRepoPath(t *testing.T) {
9296 }
9397
9498 func TestParseVisibility(t *testing.T) {
99+ t.Parallel()
95100 for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} {
96101 got, err := domain.ParseVisibility(in)
97102 require.NoError(t, err)
@@ -104,6 +109,7 @@ func TestParseVisibility(t *testing.T) {
104109 }
105110
106111 func TestPrincipal(t *testing.T) {
112+ t.Parallel()
107113 var zero domain.Principal
108114 assert.True(t, zero.IsAnonymous())
109115 assert.False(t, zero.IsRoot())
M internal/domain/text_test.go +6 -0
@@ -16,6 +16,7 @@ import (
1616 // textCases are the rules shared by every text; multi says whether line breaks
1717 // are allowed.
1818 func TestParseMRTitle(t *testing.T) {
19+ t.Parallel()
1920 tests := []struct {
2021 name string
2122 in string
@@ -48,6 +49,7 @@ func TestParseMRTitle(t *testing.T) {
4849 }
4950 for _, tc := range tests {
5051 t.Run(tc.name, func(t *testing.T) {
52+ t.Parallel()
5153 got, err := domain.ParseMRTitle(tc.in)
5254 if tc.reason == "" {
5355 require.NoError(t, err)
@@ -64,6 +66,7 @@ func TestParseMRTitle(t *testing.T) {
6466 }
6567
6668 func TestParsePlainText(t *testing.T) {
69+ t.Parallel()
6770 tests := []struct {
6871 name string
6972 in string
@@ -92,6 +95,7 @@ func TestParsePlainText(t *testing.T) {
9295 }
9396 for _, tc := range tests {
9497 t.Run(tc.name, func(t *testing.T) {
98+ t.Parallel()
9599 got, err := domain.ParsePlainText(tc.in)
96100 if tc.reason == "" {
97101 require.NoError(t, err)
@@ -107,6 +111,7 @@ func TestParsePlainText(t *testing.T) {
107111 }
108112
109113 func TestPlainTextIsBlank(t *testing.T) {
114+ t.Parallel()
110115 for in, blank := range map[string]bool{"": true, " \n\n ": true, "x": false, " x ": false} {
111116 got, err := domain.ParsePlainText(in)
112117 require.NoError(t, err)
@@ -115,6 +120,7 @@ func TestPlainTextIsBlank(t *testing.T) {
115120 }
116121
117122 func TestViolations(t *testing.T) {
123+ t.Parallel()
118124 var v domain.Violations
119125 require.NoError(t, v.Err())
120126
M internal/domain/token_test.go +6 -0
@@ -13,6 +13,7 @@ import (
1313 )
1414
1515 func TestScopes(t *testing.T) {
16+ t.Parallel()
1617 all := domain.AllScopes()
1718 assert.Len(t, all, 9)
1819 for _, s := range all {
@@ -27,6 +28,7 @@ func TestScopes(t *testing.T) {
2728 }
2829
2930 func TestHashTokenSecret(t *testing.T) {
31+ t.Parallel()
3032 h := domain.HashTokenSecret("ermite_abc")
3133 assert.Equal(t, h, domain.HashTokenSecret("ermite_abc"))
3234 assert.NotEqual(t, h, domain.HashTokenSecret("ermite_abd"))
@@ -36,6 +38,7 @@ func TestHashTokenSecret(t *testing.T) {
3638 }
3739
3840 func TestTokenExpired(t *testing.T) {
41+ t.Parallel()
3942 now := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
4043 assert.False(t, domain.Token{}.Expired(now), "no expiry never expires")
4144 assert.False(t, domain.Token{ExpiresAt: now.Add(time.Second)}.Expired(now))
@@ -44,6 +47,7 @@ func TestTokenExpired(t *testing.T) {
4447 }
4548
4649 func TestPrincipalScopes(t *testing.T) {
50+ t.Parallel()
4751 a, b := mustName(t, "a"), mustName(t, "b")
4852 tok := domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, Repos: []domain.RepoName{a}}
4953 p := domain.TokenPrincipal(tok)
@@ -84,6 +88,7 @@ func mustName(t *testing.T, s string) domain.RepoName {
8488 }
8589
8690 func TestOwnerPrincipal(t *testing.T) {
91+ t.Parallel()
8792 o := domain.Owner()
8893 assert.Equal(t, "owner", o.Label())
8994 assert.True(t, o.IsOwner())
@@ -96,6 +101,7 @@ func TestOwnerPrincipal(t *testing.T) {
96101 }
97102
98103 func TestHasOwnerRights(t *testing.T) {
104+ t.Parallel()
99105 a := mustName(t, "a")
100106 all := domain.AllScopes()
101107 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-// Package ports declares the interfaces the use cases need from the outside
4-// world. Adapters implement them; cmd wires them.
3+// Package ports declares every interface that crosses a layer, and the
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.
512 package ports
613
714 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -15,22 +15,22 @@ import (
1515 "github.com/stretchr/testify/mock"
1616 "github.com/stretchr/testify/require"
1717
18- "bnema.dev/ermite/internal/app"
1918 "bnema.dev/ermite/internal/domain"
2019 "bnema.dev/ermite/internal/ports"
2120 "bnema.dev/ermite/internal/ports/mocks"
21+ "bnema.dev/ermite/internal/usecase"
2222 )
2323
2424 type browseFixture struct {
2525 meta *mocks.RepoMetaStore
2626 reader *mocks.RepoReader
27- browse *app.Browse
27+ browse *usecase.Browse
2828 }
2929
3030 func newBrowseFixture(t *testing.T) *browseFixture {
3131 t.Helper()
3232 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)
3434 return f
3535 }
3636
@@ -46,7 +46,7 @@ func oid(c string) domain.ObjectID {
4646 // The reader expectation of each is what must (not) run.
4747 type browseOp struct {
4848 name string
49- call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
49+ call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
5050 // expect registers the reader call a permitted caller makes.
5151 expect func(r *mocks.RepoReader)
5252 }
@@ -54,35 +54,35 @@ type browseOp struct {
5454 func browseOps() []browseOp {
5555 root := domain.TreePath{}
5656 return []browseOp{
57- {"ListRefs", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
58- _, err := b.ListRefs(ctx, p, repo, app.ListRefsParams{})
57+ {"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
58+ _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{})
5959 return err
6060 }, 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 {
6262 _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
6363 return err
6464 }, func(r *mocks.RepoReader) {
6565 r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
6666 }},
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 {
6868 _, err := b.ResolveRefPath(ctx, p, repo, "main/x")
6969 return err
7070 }, func(r *mocks.RepoReader) {
7171 r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
7272 }},
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 {
7474 _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
7575 return err
7676 }, func(r *mocks.RepoReader) {
7777 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
7878 }},
79- {"ListPaths", func(b *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 {
8080 _, err := b.ListPaths(ctx, p, repo, domain.Revision{})
8181 return err
8282 }, func(r *mocks.RepoReader) {
8383 r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil)
8484 }},
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 {
8686 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
8787 if rc != nil {
8888 _ = rc.Close()
@@ -92,13 +92,13 @@ func browseOps() []browseOp {
9292 r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
9393 Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
9494 }},
95- {"ListCommits", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
96- _, err := b.ListCommits(ctx, p, repo, app.ListCommitsParams{})
95+ {"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
96+ _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{})
9797 return err
9898 }, func(r *mocks.RepoReader) {
9999 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
100100 }},
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 {
102102 _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
103103 return err
104104 }, func(r *mocks.RepoReader) {
@@ -112,6 +112,7 @@ func browseOps() []browseOp {
112112 // not found; stored tokens need repo:read on the repository; and the Git
113113 // reader is never reached when access is refused.
114114 func TestBrowseAccess(t *testing.T) {
115+ t.Parallel()
115116 ctx := context.Background()
116117 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
117118 priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
@@ -135,6 +136,7 @@ func TestBrowseAccess(t *testing.T) {
135136 for _, pr := range principals {
136137 for _, repo := range []domain.Repo{pub, priv} {
137138 t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) {
139+ t.Parallel()
138140 f := newBrowseFixture(t)
139141 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
140142 allowed := pr.pub
@@ -155,12 +157,14 @@ func TestBrowseAccess(t *testing.T) {
155157 }
156158 }
157159 t.Run(op.name+"/missing repository", func(t *testing.T) {
160+ t.Parallel()
158161 f := newBrowseFixture(t)
159162 f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound)
160163 err := op.call(f.browse, ctx, domain.Root(), name(t, "nope"))
161164 assert.ErrorIs(t, err, domain.ErrNotFound)
162165 })
163166 t.Run(op.name+"/zero name", func(t *testing.T) {
167+ t.Parallel()
164168 f := newBrowseFixture(t)
165169 err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{})
166170 assert.ErrorIs(t, err, domain.ErrInvalidName)
@@ -169,6 +173,7 @@ func TestBrowseAccess(t *testing.T) {
169173 }
170174
171175 func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) {
176+ t.Parallel()
172177 // A reader error is passed through for a caller who may read.
173178 ctx := context.Background()
174179 f := newBrowseFixture(t)
@@ -184,6 +189,7 @@ func ref(kind domain.RefKind, name string) domain.Ref {
184189 }
185190
186191 func TestListRefsPagingAndKinds(t *testing.T) {
192+ t.Parallel()
187193 ctx := context.Background()
188194 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
189195 all := []domain.Ref{
@@ -201,7 +207,7 @@ func TestListRefsPagingAndKinds(t *testing.T) {
201207 }
202208 return out
203209 }
204- setup := func(t *testing.T, refs []domain.Ref) *app.Browse {
210+ setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse {
205211 f := newBrowseFixture(t)
206212 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
207213 f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
@@ -214,7 +220,7 @@ func TestListRefsPagingAndKinds(t *testing.T) {
214220 tok := ""
215221 for pages := 0; ; pages++ {
216222 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})
218224 require.NoError(t, err)
219225 got = append(got, names(page.Refs)...)
220226 if tok = page.NextPageToken; tok == "" {
@@ -226,19 +232,19 @@ func TestListRefsPagingAndKinds(t *testing.T) {
226232
227233 t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) {
228234 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})
230236 require.NoError(t, err)
231237 require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs))
232238
233239 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})
235241 require.NoError(t, err)
236242 assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs))
237243 })
238244
239245 t.Run("kinds filter", func(t *testing.T) {
240246 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}})
242248 require.NoError(t, err)
243249 assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs))
244250 assert.Empty(t, page.NextPageToken)
@@ -247,24 +253,26 @@ func TestListRefsPagingAndKinds(t *testing.T) {
247253 t.Run("default and maximum page size", func(t *testing.T) {
248254 var many []domain.Ref
249255 many = append(many, ref(domain.RefKindHead, "HEAD"))
250- for i := range 250 {
251- many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%03d", i)))
256+ for i := range usecase.MaxRefPageSize + 100 {
257+ many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))
252258 }
253259 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{})
255261 require.NoError(t, err)
256- assert.Len(t, page.Refs, app.DefaultPageSize)
262+ assert.Len(t, page.Refs, usecase.DefaultPageSize)
257263 assert.NotEmpty(t, page.NextPageToken)
258264
259265 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})
261267 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)
263270 })
264271
265272 t.Run("bad input never reaches the reader", func(t *testing.T) {
273+ t.Parallel()
266274 f := newBrowseFixture(t)
267- for _, params := range []app.ListRefsParams{
275+ for _, params := range []ports.ListRefsParams{
268276 {PageSize: -1},
269277 {Kinds: []domain.RefKind{"nope"}},
270278 {PageToken: "!!!"},
@@ -273,26 +281,28 @@ func TestListRefsPagingAndKinds(t *testing.T) {
273281 _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params)
274282 assert.Error(t, err, "%+v", params)
275283 }
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})
277285 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"}})
279287 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: "!!!"})
281289 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
282290 })
283291 }
284292
285293 func TestListCommitsPaging(t *testing.T) {
294+ t.Parallel()
286295 ctx := context.Background()
287296 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
288297 p := mustPath(t, "d/f")
289298
290299 t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) {
300+ t.Parallel()
291301 f := newBrowseFixture(t)
292302 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
293303 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}).
294304 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})
296306 require.NoError(t, err)
297307 require.Len(t, page.Commits, 1)
298308 require.NotEmpty(t, page.NextPageToken)
@@ -304,7 +314,7 @@ func TestListCommitsPaging(t *testing.T) {
304314 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
305315 // The revision of the request is ignored once there is a token.
306316 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{
308318 Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken,
309319 })
310320 require.NoError(t, err)
@@ -313,14 +323,15 @@ func TestListCommitsPaging(t *testing.T) {
313323 })
314324
315325 t.Run("a token is bound to its path and must be well formed", func(t *testing.T) {
326+ t.Parallel()
316327 f := newBrowseFixture(t)
317328 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
318329 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
319330 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})
321332 require.NoError(t, err)
322333
323- for _, params := range []app.ListCommitsParams{
334+ for _, params := range []ports.ListCommitsParams{
324335 {PageToken: page.NextPageToken}, // no path
325336 {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path
326337 {Path: p, PageToken: "garbage"},
@@ -333,11 +344,12 @@ func TestListCommitsPaging(t *testing.T) {
333344 })
334345
335346 t.Run("a token cannot skip deeper than the cap", func(t *testing.T) {
347+ t.Parallel()
336348 f := newBrowseFixture(t)
337349 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
338350 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
339351 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})
341353 require.NoError(t, err)
342354 raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken)
343355 require.NoError(t, err)
@@ -348,24 +360,25 @@ func TestListCommitsPaging(t *testing.T) {
348360 return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))
349361 }
350362
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 })).
352364 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)})
354366 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)})
356368 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
357369 })
358370
359371 t.Run("page size", func(t *testing.T) {
372+ t.Parallel()
360373 f := newBrowseFixture(t)
361374 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)
363376 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{})
365378 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})
367380 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})
369382 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
370383 })
371384
@@ -375,10 +388,10 @@ func TestListCommitsPaging(t *testing.T) {
375388 f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
376389 f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything).
377390 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{})
379392 require.NoError(t, err)
380393 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})
382395 assert.ErrorIs(t, err, domain.ErrNotFound)
383396 })
384397 }
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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
@@ -38,6 +38,8 @@ type Maintainer struct {
3838 wg sync.WaitGroup
3939 }
4040
41+var _ ports.MaintenanceScheduler = (*Maintainer)(nil)
42+
4143 type maintEntry struct {
4244 timer *time.Timer // pending run, nil while running or idle
4345 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-// Package app holds the use cases of ermite. It depends on the domain and on
3+// Package usecase holds the use cases of ermite. It depends on the domain and on
44 // the ports only.
5-package app
5+package usecase
66
77 import (
88 "context"
@@ -23,20 +23,14 @@ type Repos struct {
2323 clock ports.Clock
2424 }
2525
26+var _ ports.RepoService = (*Repos)(nil)
27+
2628 // NewRepos builds the repository use cases over their ports. Changes are
2729 // published on bus.
2830 func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, clock ports.Clock) *Repos {
2931 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
3032 }
3133
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-
4034 // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
4135 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,
4236 // also when a concurrent Create wins) and publishes the directory. A failure
@@ -45,7 +39,7 @@ type CreateParams struct {
4539 //
4640 // The principal needs repo:write on the name: anonymous callers get
4741 // 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) {
4943 if params.Name.IsZero() {
5044 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
5145 }
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 renamed, 91% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
@@ -40,30 +40,14 @@ type Tokens struct {
4040 sessions ports.SessionStore
4141 }
4242
43+var _ ports.TokenService = (*Tokens)(nil)
44+
4345 // NewTokens builds the token use cases over their ports. sessions is where
4446 // revoking a token with owner rights ends every web session (see Revoke).
4547 func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.SessionStore) *Tokens {
4648 return &Tokens{store: store, clock: clock, sessions: sessions}
4749 }
4850
49-// CreateTokenParams are the settings of a new token.
50-type CreateTokenParams struct {
51- Label string
52- // Scopes must not be empty.
53- Scopes []domain.Scope
54- // Repos limits the token to these repositories; empty means every one.
55- Repos []domain.RepoName
56- // ExpiresAt is the zero time for a token that never expires.
57- ExpiresAt time.Time
58-}
59-
60-// CreatedToken is a new token with its secret, which is shown once and never
61-// stored.
62-type CreatedToken struct {
63- Token domain.Token
64- Secret string
65-}
66-
6751 // Create makes a stored token and returns it with its secret.
6852 //
6953 // The principal needs token:admin: anonymous callers get
@@ -75,20 +59,20 @@ type CreatedToken struct {
7559 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which
7660 // are reserved, in any case and around spaces), an unknown scope
7761 // domain.ErrInvalidScope.
78-func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error) {
62+func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.CreateTokenParams) (ports.CreatedToken, error) {
7963 if p.IsAnonymous() {
80- return CreatedToken{}, domain.ErrUnauthenticated
64+ return ports.CreatedToken{}, domain.ErrUnauthenticated
8165 }
8266 if !canManageTokens(p) {
83- return CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
67+ return ports.CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
8468 }
8569 now := t.clock.Now().UTC()
8670 tok, err := buildToken(params, now)
8771 if err != nil {
88- return CreatedToken{}, err
72+ return ports.CreatedToken{}, err
8973 }
9074 if err := checkNoEscalation(p, tok); err != nil {
91- return CreatedToken{}, err
75+ return ports.CreatedToken{}, err
9276 }
9377
9478 for range 3 {
@@ -99,7 +83,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTo
9983 continue // a random id or secret collided: practically never
10084 }
10185 if err != nil {
102- return CreatedToken{}, fmt.Errorf("store token: %w", err)
86+ return ports.CreatedToken{}, fmt.Errorf("store token: %w", err)
10387 }
10488 log := zerowrap.FromCtx(ctx)
10589 log.Info().
@@ -107,13 +91,13 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTo
10791 Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).
10892 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
10993 Msg("token created")
110- return CreatedToken{Token: tok, Secret: secret}, nil
94+ return ports.CreatedToken{Token: tok, Secret: secret}, nil
11195 }
112- return CreatedToken{}, errors.New("store token: could not find a free id")
96+ return ports.CreatedToken{}, errors.New("store token: could not find a free id")
11397 }
11498
11599 // buildToken validates the settings and returns the token without id and hash.
116-func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) {
100+func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, error) {
117101 if params.Label == "" || len(params.Label) > MaxTokenLabelLen || !utf8.ValidString(params.Label) {
118102 return domain.Token{}, fmt.Errorf("%w: label must be 1 to %d bytes of UTF-8", domain.ErrInvalidToken, MaxTokenLabelLen)
119103 }
@@ -274,6 +258,11 @@ type Auth struct {
274258 clock ports.Clock
275259 }
276260
261+var (
262+ _ ports.AuthService = (*Auth)(nil)
263+ _ ports.Revalidator = (*Auth)(nil)
264+)
265+
277266 // NewAuth builds the authentication use case. rootHash is the SHA-256 of the
278267 // admin token.
279268 func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth {
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