detached 86b5a04

merge: push mirror warning and badge into feat/push-mirror

commit
86b5a041e96363e1558a89c859d82b211a911896
author
brice · · 2026-10-20 05:54 UTC
committer
brice · · 2026-10-20 10:02 UTC
merge of
ed419c7 + 475ebb6 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +501 -272
M internal/adapters/githttp/hooks.go +30 -3
@@ -37,8 +37,9 @@ type hookEntry struct {
3737 // HookServer registers the per-request hook tokens and serves the Unix
3838 // socket the hooks call back on. It implements HookTokens.
3939 type HookServer struct {
40- path string
41- hooks ports.PushHookService
40+ path string
41+ hooks ports.PushHookService
42+ mirrors ports.MirrorWarner
4243
4344 mu sync.RWMutex
4445 tokens map[string]hookEntry
@@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer {
5657 return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}
5758 }
5859
60+// WithMirrorWarner enables best-effort mirror warnings. Configure before serving.
61+func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer {
62+ s.mirrors = w
63+ return s
64+}
65+
66+// mirrorWarning bounds even a source that does not honor context cancellation.
67+func (s *HookServer) mirrorWarning(ctx context.Context, repo domain.RepoName) string {
68+ if s.mirrors == nil {
69+ return ""
70+ }
71+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
72+ defer cancel()
73+ result := make(chan string, 1)
74+ go func() { result <- s.mirrors.Warning(ctx, repo) }()
75+ select {
76+ case warning := <-result:
77+ if ctx.Err() != nil || warning == "" {
78+ return ""
79+ }
80+ return warning + "\n→ ermite mirror status " + repo.String()
81+ case <-ctx.Done():
82+ return ""
83+ }
84+}
85+
5986 // Socket returns the path of the socket.
6087 func (s *HookServer) Socket() string { return s.path }
6188
@@ -229,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
229256 s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})
230257 return
231258 }
232- s.reply(ctx, conn, HookReply{OK: true})
259+ s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.repo)})
233260 }
234261 }
235262
@@ -37,8 +37,9 @@ type hookEntry struct {
37 // HookServer registers the per-request hook tokens and serves the Unix37 // HookServer registers the per-request hook tokens and serves the Unix
38 // socket the hooks call back on. It implements HookTokens.38 // socket the hooks call back on. It implements HookTokens.
39 type HookServer struct {39 type HookServer struct {
40- path string40+ path string
41- hooks ports.PushHookService41+ hooks ports.PushHookService
42+ mirrors ports.MirrorWarner
42 43
43 mu sync.RWMutex44 mu sync.RWMutex
44 tokens map[string]hookEntry45 tokens map[string]hookEntry
@@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer {
56 return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}57 return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}
57 }58 }
58 59
60+// WithMirrorWarner enables best-effort mirror warnings. Configure before serving.
61+func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer {
62+ s.mirrors = w
63+ return s
64+}
65+
66+// mirrorWarning bounds even a source that does not honor context cancellation.
67+func (s *HookServer) mirrorWarning(ctx context.Context, repo domain.RepoName) string {
68+ if s.mirrors == nil {
69+ return ""
70+ }
71+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
72+ defer cancel()
73+ result := make(chan string, 1)
74+ go func() { result <- s.mirrors.Warning(ctx, repo) }()
75+ select {
76+ case warning := <-result:
77+ if ctx.Err() != nil || warning == "" {
78+ return ""
79+ }
80+ return warning + "\n→ ermite mirror status " + repo.String()
81+ case <-ctx.Done():
82+ return ""
83+ }
84+}
85+
59 // Socket returns the path of the socket.86 // Socket returns the path of the socket.
60 func (s *HookServer) Socket() string { return s.path }87 func (s *HookServer) Socket() string { return s.path }
61 88
@@ -229,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
229 s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})256 s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})
230 return257 return
231 }258 }
232- s.reply(ctx, conn, HookReply{OK: true})259+ s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.repo)})
233 }260 }
234 }261 }
235 262
M internal/adapters/githttp/hooks_test.go +46 -0
@@ -412,3 +412,49 @@ func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
412412 require.NoError(t, err)
413413 assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
414414 }
415+
416+func TestMirrorPostReceiveWarning(t *testing.T) {
417+ for _, warning := range []string{"ermite: mirror is failing: network unavailable", "ermite: mirror is paused: main diverged on remote", ""} {
418+ t.Run(warning, func(t *testing.T) {
419+ hooks := mocks.NewPushHookService(t)
420+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
421+ srv, sock := startHooks(t, hooks)
422+ w := mocks.NewMirrorWarner(t)
423+ w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).Return(warning)
424+ srv.WithMirrorWarner(w)
425+ token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
426+ defer release()
427+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
428+ assert.True(t, reply.OK)
429+ if warning == "" {
430+ assert.Empty(t, reply.Message)
431+ } else {
432+ assert.Equal(t, warning+"\n→ ermite mirror status proj", reply.Message)
433+ }
434+ })
435+ }
436+}
437+
438+func TestMirrorPostReceiveTimeout(t *testing.T) {
439+ hooks := mocks.NewPushHookService(t)
440+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
441+ srv, sock := startHooks(t, hooks)
442+ w := mocks.NewMirrorWarner(t)
443+ release := make(chan struct{})
444+ defer close(release)
445+ w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.RepoName) string {
446+ deadline, ok := ctx.Deadline()
447+ assert.True(t, ok)
448+ assert.LessOrEqual(t, time.Until(deadline), 2*time.Second)
449+ <-release // deliberately ignores cancellation
450+ return "late warning"
451+ })
452+ srv.WithMirrorWarner(w)
453+ token, revoke := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
454+ defer revoke()
455+ start := time.Now()
456+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
457+ assert.True(t, reply.OK)
458+ assert.Empty(t, reply.Message)
459+ assert.Less(t, time.Since(start), 2500*time.Millisecond)
460+}
@@ -412,3 +412,49 @@ func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
412 require.NoError(t, err)412 require.NoError(t, err)
413 assert.Equal(t, "line\n", string(in), "stdin reaches the binary")413 assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
414 }414 }
415+
416+func TestMirrorPostReceiveWarning(t *testing.T) {
417+ for _, warning := range []string{"ermite: mirror is failing: network unavailable", "ermite: mirror is paused: main diverged on remote", ""} {
418+ t.Run(warning, func(t *testing.T) {
419+ hooks := mocks.NewPushHookService(t)
420+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
421+ srv, sock := startHooks(t, hooks)
422+ w := mocks.NewMirrorWarner(t)
423+ w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).Return(warning)
424+ srv.WithMirrorWarner(w)
425+ token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
426+ defer release()
427+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
428+ assert.True(t, reply.OK)
429+ if warning == "" {
430+ assert.Empty(t, reply.Message)
431+ } else {
432+ assert.Equal(t, warning+"\n→ ermite mirror status proj", reply.Message)
433+ }
434+ })
435+ }
436+}
437+
438+func TestMirrorPostReceiveTimeout(t *testing.T) {
439+ hooks := mocks.NewPushHookService(t)
440+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
441+ srv, sock := startHooks(t, hooks)
442+ w := mocks.NewMirrorWarner(t)
443+ release := make(chan struct{})
444+ defer close(release)
445+ w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.RepoName) string {
446+ deadline, ok := ctx.Deadline()
447+ assert.True(t, ok)
448+ assert.LessOrEqual(t, time.Until(deadline), 2*time.Second)
449+ <-release // deliberately ignores cancellation
450+ return "late warning"
451+ })
452+ srv.WithMirrorWarner(w)
453+ token, revoke := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
454+ defer revoke()
455+ start := time.Now()
456+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
457+ assert.True(t, reply.OK)
458+ assert.Empty(t, reply.Message)
459+ assert.Less(t, time.Since(start), 2500*time.Millisecond)
460+}
A internal/adapters/web/mirror.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+
8+ "bnema.dev/ermite/internal/domain"
9+ "bnema.dev/ermite/internal/ports"
10+)
11+
12+// MirrorBadge is an administrative diagnostic in the summary rail.
13+type MirrorBadge struct{ State, Reason, Command string }
14+
15+// WithMirrorWarner enables mirror badges. Configure before serving requests.
16+func (s *Server) WithMirrorWarner(w ports.MirrorWarner) *Server {
17+ s.mirrors = w
18+ return s
19+}
20+
21+// mirrorBadge selects presentation only; the source authorizes the lookup and reason.
22+//
23+//hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator
24+func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge {
25+ p := principal(ctx)
26+ if s.mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {
27+ return nil
28+ }
29+ state, reason := s.mirrors.Badge(ctx, p, repo)
30+ if state != domain.MirrorFailing && state != domain.MirrorPaused {
31+ return nil
32+ }
33+ return &MirrorBadge{State: string(state), Reason: reason, Command: "ermite mirror status " + repo.String()}
34+}
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+
8+ "bnema.dev/ermite/internal/domain"
9+ "bnema.dev/ermite/internal/ports"
10+)
11+
12+// MirrorBadge is an administrative diagnostic in the summary rail.
13+type MirrorBadge struct{ State, Reason, Command string }
14+
15+// WithMirrorWarner enables mirror badges. Configure before serving requests.
16+func (s *Server) WithMirrorWarner(w ports.MirrorWarner) *Server {
17+ s.mirrors = w
18+ return s
19+}
20+
21+// mirrorBadge selects presentation only; the source authorizes the lookup and reason.
22+//
23+//hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator
24+func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge {
25+ p := principal(ctx)
26+ if s.mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {
27+ return nil
28+ }
29+ state, reason := s.mirrors.Badge(ctx, p, repo)
30+ if state != domain.MirrorFailing && state != domain.MirrorPaused {
31+ return nil
32+ }
33+ return &MirrorBadge{State: string(state), Reason: reason, Command: "ermite mirror status " + repo.String()}
34+}
A internal/adapters/web/mirror_test.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+)
14+
15+func TestMirrorBadge(t *testing.T) {
16+ repo := repoName(t, "proj")
17+ admin := domain.TokenPrincipal(domain.Token{ID: "admin", Scopes: []domain.Scope{domain.ScopeRepoAdmin}, Repos: []domain.RepoName{repo}})
18+ reader := domain.TokenPrincipal(domain.Token{ID: "reader", Scopes: []domain.Scope{domain.ScopeRepoRead}})
19+ for _, tc := range []struct {
20+ name string
21+ p domain.Principal
22+ state domain.MirrorState
23+ shown bool
24+ lookup bool
25+ }{
26+ {"owner failing", domain.Owner(), domain.MirrorFailing, true, true},
27+ {"admin paused", admin, domain.MirrorPaused, true, true},
28+ {"anonymous", domain.Anonymous(), domain.MirrorFailing, false, false},
29+ {"reader", reader, domain.MirrorFailing, false, false},
30+ {"ok", admin, domain.MirrorOK, false, true},
31+ {"disabled", admin, domain.MirrorDisabled, false, true},
32+ } {
33+ t.Run(tc.name, func(t *testing.T) {
34+ w := mocks.NewMirrorWarner(t)
35+ if tc.lookup {
36+ w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote")
37+ }
38+ s := (&Server{}).WithMirrorWarner(w)
39+ badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo)
40+ out := renderRail(t, SummaryPage{Mirror: badge})
41+ if tc.shown {
42+ assert.Contains(t, out, `aria-labelledby="r-mirror"`)
43+ assert.Contains(t, out, "Mirror "+string(tc.state))
44+ assert.Contains(t, out, "main diverged on remote")
45+ assert.Contains(t, out, "ermite mirror status proj")
46+ } else {
47+ assert.Nil(t, badge)
48+ assert.NotContains(t, out, `id="r-mirror"`)
49+ }
50+ })
51+ }
52+ assert.Nil(t, (&Server{}).mirrorBadge(domain.ContextWithPrincipal(t.Context(), domain.Owner()), repo))
53+}
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+)
14+
15+func TestMirrorBadge(t *testing.T) {
16+ repo := repoName(t, "proj")
17+ admin := domain.TokenPrincipal(domain.Token{ID: "admin", Scopes: []domain.Scope{domain.ScopeRepoAdmin}, Repos: []domain.RepoName{repo}})
18+ reader := domain.TokenPrincipal(domain.Token{ID: "reader", Scopes: []domain.Scope{domain.ScopeRepoRead}})
19+ for _, tc := range []struct {
20+ name string
21+ p domain.Principal
22+ state domain.MirrorState
23+ shown bool
24+ lookup bool
25+ }{
26+ {"owner failing", domain.Owner(), domain.MirrorFailing, true, true},
27+ {"admin paused", admin, domain.MirrorPaused, true, true},
28+ {"anonymous", domain.Anonymous(), domain.MirrorFailing, false, false},
29+ {"reader", reader, domain.MirrorFailing, false, false},
30+ {"ok", admin, domain.MirrorOK, false, true},
31+ {"disabled", admin, domain.MirrorDisabled, false, true},
32+ } {
33+ t.Run(tc.name, func(t *testing.T) {
34+ w := mocks.NewMirrorWarner(t)
35+ if tc.lookup {
36+ w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote")
37+ }
38+ s := (&Server{}).WithMirrorWarner(w)
39+ badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo)
40+ out := renderRail(t, SummaryPage{Mirror: badge})
41+ if tc.shown {
42+ assert.Contains(t, out, `aria-labelledby="r-mirror"`)
43+ assert.Contains(t, out, "Mirror "+string(tc.state))
44+ assert.Contains(t, out, "main diverged on remote")
45+ assert.Contains(t, out, "ermite mirror status proj")
46+ } else {
47+ assert.Nil(t, badge)
48+ assert.NotContains(t, out, `id="r-mirror"`)
49+ }
50+ })
51+ }
52+ assert.Nil(t, (&Server{}).mirrorBadge(domain.ContextWithPrincipal(t.Context(), domain.Owner()), repo))
53+}
M internal/adapters/web/pages.templ +11 -0
@@ -92,6 +92,17 @@ templ latestTagFact(t LatestTag) {
9292 }
9393
9494 templ summaryRail(p SummaryPage) {
95+ if p.Mirror != nil {
96+ <section aria-labelledby="r-mirror">
97+ <h2 id="r-mirror">Mirror</h2>
98+ <p>⚠ Mirror { p.Mirror.State }</p>
99+ if p.Mirror.Reason != "" {
100+ <p>{ p.Mirror.Reason }</p>
101+ }
102+ <p>Run <code>{ p.Mirror.Command }</code></p>
103+ </section>
104+ }
105+
95106 <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)
96107 </section>
97108 <section aria-labelledby="r-facts">
@@ -92,6 +92,17 @@ templ latestTagFact(t LatestTag) {
92 }92 }
93 93
94 templ summaryRail(p SummaryPage) {94 templ summaryRail(p SummaryPage) {
95+ if p.Mirror != nil {
96+ <section aria-labelledby="r-mirror">
97+ <h2 id="r-mirror">Mirror</h2>
98+ <p>⚠ Mirror { p.Mirror.State }</p>
99+ if p.Mirror.Reason != "" {
100+ <p>{ p.Mirror.Reason }</p>
101+ }
102+ <p>Run <code>{ p.Mirror.Command }</code></p>
103+ </section>
104+ }
105+
95 <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)106 <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)
96 </section>107 </section>
97 <section aria-labelledby="r-facts">108 <section aria-labelledby="r-facts">
M internal/adapters/web/pages_templ.go +324 -269 generated file: collapsed
M internal/adapters/web/summary.go +1 -0
@@ -49,6 +49,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
4949 now := s.cfg.Now()
5050
5151 p := SummaryPage{
52+ Mirror: s.mirrorBadge(ctx, d.repo.Name),
5253 AllHref: repoHref(d.name(), "commits", ref),
5354 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
5455 }
@@ -49,6 +49,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
49 now := s.cfg.Now()49 now := s.cfg.Now()
50 50
51 p := SummaryPage{51 p := SummaryPage{
52+ Mirror: s.mirrorBadge(ctx, d.repo.Name),
52 AllHref: repoHref(d.name(), "commits", ref),53 AllHref: repoHref(d.name(), "commits", ref),
53 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},54 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
54 }55 }
M internal/adapters/web/view.go +1 -0
@@ -203,6 +203,7 @@ type CommitRow struct {
203203
204204 // SummaryPage is the repository summary.
205205 type SummaryPage struct {
206+ Mirror *MirrorBadge
206207 Facts []Fact
207208 Clone Clone
208209 Recent []CommitRow
@@ -203,6 +203,7 @@ type CommitRow struct {
203 203
204 // SummaryPage is the repository summary.204 // SummaryPage is the repository summary.
205 type SummaryPage struct {205 type SummaryPage struct {
206+ Mirror *MirrorBadge
206 Facts []Fact207 Facts []Fact
207 Clone Clone208 Clone Clone
208 Recent []CommitRow209 Recent []CommitRow
M internal/adapters/web/web.go +1 -0
@@ -127,6 +127,7 @@ type Config struct {
127127
128128 // Server serves the pages. It is an http.Handler.
129129 type Server struct {
130+ mirrors ports.MirrorWarner
130131 cfg Config
131132 instance string
132133 origin string // scheme://host of the public URL, what a POST must come from
@@ -127,6 +127,7 @@ type Config struct {
127 127
128 // Server serves the pages. It is an http.Handler.128 // Server serves the pages. It is an http.Handler.
129 type Server struct {129 type Server struct {
130+ mirrors ports.MirrorWarner
130 cfg Config131 cfg Config
131 instance string132 instance string
132 origin string // scheme://host of the public URL, what a POST must come from133 origin string // scheme://host of the public URL, what a POST must come from

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 44

↑↓ moveenter openesc close