detached 5dd3383

test: pin pause ordering, dropped notifications and zero-alloc commit

Cover Roundtrip/Bind queueing, ring order across a pause, unwatch with a
queued notification, Close inside FDReady and v1 outputs. The alloc guard
now includes wl_output.done; headless test and wire peer drop the
countingHandler/isConnReset helpers.
commit
5dd338336beae58b9671dea6e4c723d5c454c3c7
author
brice · · 2026-10-01 15:41 UTC
parent
77d28a9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +89 -32
M alloc_test.go +10 -8
@@ -12,16 +12,17 @@ import (
1212
1313 // countingHandler is the measured system's input, not a double of a port: it
1414 // only counts, so the test sees the cost of the library alone.
15-type countingHandler struct{ fdReady, added, removed, errs int }
15+type countingHandler struct {
16+ neferclient.NopHandler
17+ fdReady, errs int
18+}
1619
17-func (h *countingHandler) OutputAdded(*neferclient.Output) { h.added++ }
18-func (h *countingHandler) OutputRemoved(uint32) { h.removed++ }
19-func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
20-func (h *countingHandler) Error(error) { h.errs++ }
20+func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
21+func (h *countingHandler) Error(error) { h.errs++ }
2122
2223 // TestAllocDispatch measures the whole steady-state pipeline over a real
2324 // socketpair: wire frames in, reader decode, queue, Dispatch (output state
24-// updates and a watched-fd notification with epoll re-arm). testing's
25+// updates, an output commit and a watched-fd notification with epoll re-arm). testing's
2526 // AllocsPerRun counts every goroutine, so reader and epoll allocations are
2627 // included.
2728 func TestAllocDispatch(t *testing.T) {
@@ -34,6 +35,7 @@ func TestAllocDispatch(t *testing.T) {
3435 for i := range scaleEvents {
3536 batch = append(batch, frame(obj.id, 3, appendU32(nil, uint32(1+i%2)))...)
3637 }
38+ batch = append(batch, frame(obj.id, 2, nil)...) // wl_output.done: commitOutput runs
3739 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
3840 require.NoError(t, err)
3941 defer unix.Close(efd)
@@ -47,7 +49,7 @@ func TestAllocDispatch(t *testing.T) {
4749 t.Fatal(err)
4850 }
4951 _, _ = unix.Write(efd, one[:])
50- for c.QueueLen() < scaleEvents+1 {
52+ for c.QueueLen() < scaleEvents+2 {
5153 runtime.Gosched()
5254 }
5355 _, _ = unix.Read(efd, drain[:])
@@ -58,7 +60,7 @@ func TestAllocDispatch(t *testing.T) {
5860 step() // warm-up beyond AllocsPerRun's own: grow pools and buffers
5961 step()
6062 allocs := testing.AllocsPerRun(50, step)
61- t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+1, allocs)
63+ t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+2, allocs)
6264 require.Zero(t, allocs)
6365 require.Positive(t, h.fdReady)
6466 require.Zero(t, h.errs)
@@ -12,16 +12,17 @@ import (
12 12
13 // countingHandler is the measured system's input, not a double of a port: it13 // countingHandler is the measured system's input, not a double of a port: it
14 // only counts, so the test sees the cost of the library alone.14 // only counts, so the test sees the cost of the library alone.
15-type countingHandler struct{ fdReady, added, removed, errs int }15+type countingHandler struct {
16+ neferclient.NopHandler
17+ fdReady, errs int
18+}
16 19
17-func (h *countingHandler) OutputAdded(*neferclient.Output) { h.added++ }20+func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
18-func (h *countingHandler) OutputRemoved(uint32) { h.removed++ }21+func (h *countingHandler) Error(error) { h.errs++ }
19-func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
20-func (h *countingHandler) Error(error) { h.errs++ }
21 22
22 // TestAllocDispatch measures the whole steady-state pipeline over a real23 // TestAllocDispatch measures the whole steady-state pipeline over a real
23 // socketpair: wire frames in, reader decode, queue, Dispatch (output state24 // socketpair: wire frames in, reader decode, queue, Dispatch (output state
24-// updates and a watched-fd notification with epoll re-arm). testing's25+// updates, an output commit and a watched-fd notification with epoll re-arm). testing's
25 // AllocsPerRun counts every goroutine, so reader and epoll allocations are26 // AllocsPerRun counts every goroutine, so reader and epoll allocations are
26 // included.27 // included.
27 func TestAllocDispatch(t *testing.T) {28 func TestAllocDispatch(t *testing.T) {
@@ -34,6 +35,7 @@ func TestAllocDispatch(t *testing.T) {
34 for i := range scaleEvents {35 for i := range scaleEvents {
35 batch = append(batch, frame(obj.id, 3, appendU32(nil, uint32(1+i%2)))...)36 batch = append(batch, frame(obj.id, 3, appendU32(nil, uint32(1+i%2)))...)
36 }37 }
38+ batch = append(batch, frame(obj.id, 2, nil)...) // wl_output.done: commitOutput runs
37 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)39 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
38 require.NoError(t, err)40 require.NoError(t, err)
39 defer unix.Close(efd)41 defer unix.Close(efd)
@@ -47,7 +49,7 @@ func TestAllocDispatch(t *testing.T) {
47 t.Fatal(err)49 t.Fatal(err)
48 }50 }
49 _, _ = unix.Write(efd, one[:])51 _, _ = unix.Write(efd, one[:])
50- for c.QueueLen() < scaleEvents+1 {52+ for c.QueueLen() < scaleEvents+2 {
51 runtime.Gosched()53 runtime.Gosched()
52 }54 }
53 _, _ = unix.Read(efd, drain[:])55 _, _ = unix.Read(efd, drain[:])
@@ -58,7 +60,7 @@ func TestAllocDispatch(t *testing.T) {
58 step() // warm-up beyond AllocsPerRun's own: grow pools and buffers60 step() // warm-up beyond AllocsPerRun's own: grow pools and buffers
59 step()61 step()
60 allocs := testing.AllocsPerRun(50, step)62 allocs := testing.AllocsPerRun(50, step)
61- t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+1, allocs)63+ t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+2, allocs)
62 require.Zero(t, allocs)64 require.Zero(t, allocs)
63 require.Positive(t, h.fdReady)65 require.Positive(t, h.fdReady)
64 require.Zero(t, h.errs)66 require.Zero(t, h.errs)
M conn_test.go +75 -7
@@ -61,7 +61,9 @@ func TestConnectInitialOutputs(t *testing.T) {
6161 assert.Equal(t, uint32(4), srv.waitBound(7).version, "bound at min(announced, 4)")
6262 assert.Equal(t, uint32(2), srv.waitBound(9).version)
6363
64- // Setup events are applied silently: nothing is left for the Handler.
64+ // Setup events are applied silently: nothing is left for the Handler. The
65+ // roundtrip moves everything handled so far into the ring, so a leaked
66+ // setup event would reach the strict mock here.
6567 h := neferclientmocks.NewMockHandler(t)
6668 require.NoError(t, c.Roundtrip())
6769 require.NoError(t, c.Dispatch(h))
@@ -118,15 +120,48 @@ func TestRingBackpressureKeepsOrder(t *testing.T) {
118120 var buf []byte
119121 for i := 1; i <= total; i++ {
120122 buf = append(buf, frame(obj.id, 3, appendU32(nil, uint32(i)))...)
123+ buf = append(buf, frame(obj.id, 2, nil)...)
121124 }
122- buf = append(buf, frame(obj.id, 2, nil)...)
123125 go srv.write(buf)
124126 waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring")
125127
126128 // A pause (Roundtrip) must not deadlock against a producer blocked on the
127- // full ring, and must not reorder what it held back.
129+ // full ring, and must not reorder what it held back: the visible scale
130+ // only ever grows, ending at total.
128131 require.NoError(t, c.Roundtrip())
129- dispatchUntil(t, c, nil, func() bool { return c.Outputs()[0].Scale == total })
132+ prev := int32(0)
133+ dispatchUntil(t, c, nil, func() bool {
134+ got := c.Outputs()[0].Scale
135+ require.GreaterOrEqual(t, got, prev, "scale went backwards: events were reordered")
136+ prev = got
137+ return got == total
138+ })
139+}
140+
141+func TestRoundtripQueuesEventsForNextDispatch(t *testing.T) {
142+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
143+ obj := srv.waitBound(7)
144+ srv.write(frame(obj.id, 3, appendU32(nil, 4))) // scale 4
145+ srv.write(frame(obj.id, 2, nil)) // done
146+ require.NoError(t, c.Roundtrip())
147+ // No Wake wait: the events handled during the roundtrip are already queued.
148+ require.NoError(t, c.Dispatch(nil))
149+ assert.Equal(t, int32(4), c.Outputs()[0].Scale)
150+}
151+
152+func TestBindQueuesEventsForNextDispatch(t *testing.T) {
153+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}, {3, wayland.CompositorInterface, 6}},
154+ map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
155+ obj := srv.waitBound(7)
156+ srv.write(frame(obj.id, 3, appendU32(nil, 2)))
157+ srv.write(frame(obj.id, 2, nil))
158+ // The bind pauses the reader; wait until the reader has the events so they
159+ // are handled during the pause or before it, never after Bind returns.
160+ waitFor(t, func() bool { return c.QueueLen() == 2 }, "events queued")
161+ _, _, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor)
162+ require.NoError(t, err)
163+ require.NoError(t, c.Dispatch(nil))
164+ assert.Equal(t, int32(2), c.Outputs()[0].Scale)
130165 }
131166
132167 func waitFor(t *testing.T, cond func() bool, what string) {
@@ -179,9 +214,42 @@ func TestWatchFD(t *testing.T) {
179214
180215 require.NoError(t, c.UnwatchFD(efd))
181216 assert.Error(t, c.UnwatchFD(efd))
182- signal()
183- require.NoError(t, c.Roundtrip())
184- require.NoError(t, c.Dispatch(h)) // would fail the mock's Times(2)
217+}
218+
219+func TestUnwatchDropsQueuedNotification(t *testing.T) {
220+ c, _ := connectWire(t, nil, nil)
221+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
222+ require.NoError(t, err)
223+ defer unix.Close(efd)
224+ require.NoError(t, c.WatchFD(efd, 9))
225+ one := [8]byte{1}
226+ _, err = unix.Write(efd, one[:])
227+ require.NoError(t, err)
228+ waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification")
229+ require.NoError(t, c.UnwatchFD(efd))
230+ h := neferclientmocks.NewMockHandler(t) // no FDReady expectation: a call fails
231+ require.NoError(t, c.Dispatch(h))
232+}
233+
234+func TestCloseInsideFDReady(t *testing.T) {
235+ c, _ := connectWire(t, nil, nil)
236+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
237+ require.NoError(t, err)
238+ defer unix.Close(efd)
239+ require.NoError(t, c.WatchFD(efd, 1))
240+ h := neferclientmocks.NewMockHandler(t) // Error must not be called after Close
241+ h.EXPECT().FDReady(uint64(1)).Run(func(uint64) { require.NoError(t, c.Close()) }).Once()
242+ one := [8]byte{1}
243+ _, _ = unix.Write(efd, one[:])
244+ waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification")
245+ assert.ErrorIs(t, c.Dispatch(h), neferclient.ErrClosed)
246+}
247+
248+func TestOutputVersionOneIgnored(t *testing.T) {
249+ c, _ := connectWire(t, []wireGlobal{{5, outputIface, 1}, {7, outputIface, 2}},
250+ map[uint32]outputSpec{5: {"old", 1, 10, 10}, 7: {"DP-1", 1, 100, 100}})
251+ require.Len(t, c.Outputs(), 1)
252+ assert.Equal(t, uint32(7), c.Outputs()[0].Global)
185253 }
186254
187255 func TestUnwatchInsideHandler(t *testing.T) {
@@ -61,7 +61,9 @@ func TestConnectInitialOutputs(t *testing.T) {
61 assert.Equal(t, uint32(4), srv.waitBound(7).version, "bound at min(announced, 4)")61 assert.Equal(t, uint32(4), srv.waitBound(7).version, "bound at min(announced, 4)")
62 assert.Equal(t, uint32(2), srv.waitBound(9).version)62 assert.Equal(t, uint32(2), srv.waitBound(9).version)
63 63
64- // Setup events are applied silently: nothing is left for the Handler.64+ // Setup events are applied silently: nothing is left for the Handler. The
65+ // roundtrip moves everything handled so far into the ring, so a leaked
66+ // setup event would reach the strict mock here.
65 h := neferclientmocks.NewMockHandler(t)67 h := neferclientmocks.NewMockHandler(t)
66 require.NoError(t, c.Roundtrip())68 require.NoError(t, c.Roundtrip())
67 require.NoError(t, c.Dispatch(h))69 require.NoError(t, c.Dispatch(h))
@@ -118,15 +120,48 @@ func TestRingBackpressureKeepsOrder(t *testing.T) {
118 var buf []byte120 var buf []byte
119 for i := 1; i <= total; i++ {121 for i := 1; i <= total; i++ {
120 buf = append(buf, frame(obj.id, 3, appendU32(nil, uint32(i)))...)122 buf = append(buf, frame(obj.id, 3, appendU32(nil, uint32(i)))...)
123+ buf = append(buf, frame(obj.id, 2, nil)...)
121 }124 }
122- buf = append(buf, frame(obj.id, 2, nil)...)
123 go srv.write(buf)125 go srv.write(buf)
124 waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring")126 waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring")
125 127
126 // A pause (Roundtrip) must not deadlock against a producer blocked on the128 // A pause (Roundtrip) must not deadlock against a producer blocked on the
127- // full ring, and must not reorder what it held back.129+ // full ring, and must not reorder what it held back: the visible scale
130+ // only ever grows, ending at total.
128 require.NoError(t, c.Roundtrip())131 require.NoError(t, c.Roundtrip())
129- dispatchUntil(t, c, nil, func() bool { return c.Outputs()[0].Scale == total })132+ prev := int32(0)
133+ dispatchUntil(t, c, nil, func() bool {
134+ got := c.Outputs()[0].Scale
135+ require.GreaterOrEqual(t, got, prev, "scale went backwards: events were reordered")
136+ prev = got
137+ return got == total
138+ })
139+}
140+
141+func TestRoundtripQueuesEventsForNextDispatch(t *testing.T) {
142+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
143+ obj := srv.waitBound(7)
144+ srv.write(frame(obj.id, 3, appendU32(nil, 4))) // scale 4
145+ srv.write(frame(obj.id, 2, nil)) // done
146+ require.NoError(t, c.Roundtrip())
147+ // No Wake wait: the events handled during the roundtrip are already queued.
148+ require.NoError(t, c.Dispatch(nil))
149+ assert.Equal(t, int32(4), c.Outputs()[0].Scale)
150+}
151+
152+func TestBindQueuesEventsForNextDispatch(t *testing.T) {
153+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}, {3, wayland.CompositorInterface, 6}},
154+ map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
155+ obj := srv.waitBound(7)
156+ srv.write(frame(obj.id, 3, appendU32(nil, 2)))
157+ srv.write(frame(obj.id, 2, nil))
158+ // The bind pauses the reader; wait until the reader has the events so they
159+ // are handled during the pause or before it, never after Bind returns.
160+ waitFor(t, func() bool { return c.QueueLen() == 2 }, "events queued")
161+ _, _, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor)
162+ require.NoError(t, err)
163+ require.NoError(t, c.Dispatch(nil))
164+ assert.Equal(t, int32(2), c.Outputs()[0].Scale)
130 }165 }
131 166
132 func waitFor(t *testing.T, cond func() bool, what string) {167 func waitFor(t *testing.T, cond func() bool, what string) {
@@ -179,9 +214,42 @@ func TestWatchFD(t *testing.T) {
179 214
180 require.NoError(t, c.UnwatchFD(efd))215 require.NoError(t, c.UnwatchFD(efd))
181 assert.Error(t, c.UnwatchFD(efd))216 assert.Error(t, c.UnwatchFD(efd))
182- signal()217+}
183- require.NoError(t, c.Roundtrip())218+
184- require.NoError(t, c.Dispatch(h)) // would fail the mock's Times(2)219+func TestUnwatchDropsQueuedNotification(t *testing.T) {
220+ c, _ := connectWire(t, nil, nil)
221+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
222+ require.NoError(t, err)
223+ defer unix.Close(efd)
224+ require.NoError(t, c.WatchFD(efd, 9))
225+ one := [8]byte{1}
226+ _, err = unix.Write(efd, one[:])
227+ require.NoError(t, err)
228+ waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification")
229+ require.NoError(t, c.UnwatchFD(efd))
230+ h := neferclientmocks.NewMockHandler(t) // no FDReady expectation: a call fails
231+ require.NoError(t, c.Dispatch(h))
232+}
233+
234+func TestCloseInsideFDReady(t *testing.T) {
235+ c, _ := connectWire(t, nil, nil)
236+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
237+ require.NoError(t, err)
238+ defer unix.Close(efd)
239+ require.NoError(t, c.WatchFD(efd, 1))
240+ h := neferclientmocks.NewMockHandler(t) // Error must not be called after Close
241+ h.EXPECT().FDReady(uint64(1)).Run(func(uint64) { require.NoError(t, c.Close()) }).Once()
242+ one := [8]byte{1}
243+ _, _ = unix.Write(efd, one[:])
244+ waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification")
245+ assert.ErrorIs(t, c.Dispatch(h), neferclient.ErrClosed)
246+}
247+
248+func TestOutputVersionOneIgnored(t *testing.T) {
249+ c, _ := connectWire(t, []wireGlobal{{5, outputIface, 1}, {7, outputIface, 2}},
250+ map[uint32]outputSpec{5: {"old", 1, 10, 10}, 7: {"DP-1", 1, 100, 100}})
251+ require.Len(t, c.Outputs(), 1)
252+ assert.Equal(t, uint32(7), c.Outputs()[0].Global)
185 }253 }
186 254
187 func TestUnwatchInsideHandler(t *testing.T) {255 func TestUnwatchInsideHandler(t *testing.T) {
M headless_test.go +1 -2
@@ -108,11 +108,10 @@ func TestHeadlessConnect(t *testing.T) {
108108
109109 // The compositor creates its virtual output just after the socket appears,
110110 // so it may arrive through OutputAdded instead of being there at Connect.
111- h := &countingHandler{}
112111 for len(c.Outputs()) == 0 {
113112 select {
114113 case <-c.Wake():
115- require.NoError(t, c.Dispatch(h))
114+ require.NoError(t, c.Dispatch(nil))
116115 case <-ctx.Done():
117116 t.Fatal("no output announced")
118117 }
@@ -108,11 +108,10 @@ func TestHeadlessConnect(t *testing.T) {
108 108
109 // The compositor creates its virtual output just after the socket appears,109 // The compositor creates its virtual output just after the socket appears,
110 // so it may arrive through OutputAdded instead of being there at Connect.110 // so it may arrive through OutputAdded instead of being there at Connect.
111- h := &countingHandler{}
112 for len(c.Outputs()) == 0 {111 for len(c.Outputs()) == 0 {
113 select {112 select {
114 case <-c.Wake():113 case <-c.Wake():
115- require.NoError(t, c.Dispatch(h))114+ require.NoError(t, c.Dispatch(nil))
116 case <-ctx.Done():115 case <-ctx.Done():
117 t.Fatal("no output announced")116 t.Fatal("no output announced")
118 }117 }
M wire_test.go +3 -15
@@ -3,6 +3,7 @@ package neferclient_test
33 import (
44 "context"
55 "encoding/binary"
6+ "errors"
67 "io"
78 "net"
89 "os"
@@ -108,21 +109,8 @@ func (s *wireServer) write(b []byte) {
108109 }
109110
110111 func isClosed(err error) bool {
111- return err != nil && (err == io.EOF || err == net.ErrClosed || os.IsTimeout(err) || isConnReset(err))
112-}
113-
114-func isConnReset(err error) bool {
115- for err != nil {
116- if err == unix.EPIPE || err == unix.ECONNRESET {
117- return true
118- }
119- u, ok := err.(interface{ Unwrap() error })
120- if !ok {
121- return false
122- }
123- err = u.Unwrap()
124- }
125- return false
112+ return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, os.ErrDeadlineExceeded) ||
113+ errors.Is(err, unix.EPIPE) || errors.Is(err, unix.ECONNRESET)
126114 }
127115
128116 func (s *wireServer) serve() {
@@ -3,6 +3,7 @@ package neferclient_test
3 import (3 import (
4 "context"4 "context"
5 "encoding/binary"5 "encoding/binary"
6+ "errors"
6 "io"7 "io"
7 "net"8 "net"
8 "os"9 "os"
@@ -108,21 +109,8 @@ func (s *wireServer) write(b []byte) {
108 }109 }
109 110
110 func isClosed(err error) bool {111 func isClosed(err error) bool {
111- return err != nil && (err == io.EOF || err == net.ErrClosed || os.IsTimeout(err) || isConnReset(err))112+ return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, os.ErrDeadlineExceeded) ||
112-}113+ errors.Is(err, unix.EPIPE) || errors.Is(err, unix.ECONNRESET)
113-
114-func isConnReset(err error) bool {
115- for err != nil {
116- if err == unix.EPIPE || err == unix.ECONNRESET {
117- return true
118- }
119- u, ok := err.(interface{ Unwrap() error })
120- if !ok {
121- return false
122- }
123- err = u.Unwrap()
124- }
125- return false
126 }114 }
127 115
128 func (s *wireServer) serve() {116 func (s *wireServer) serve() {

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 1

Tags 4

↑↓ moveenter openesc close