detached 5f1ad00

popup_wire_test.go RawHistoryFolder

1package neferclient_test
2
3import (
4	"encoding/binary"
5	"errors"
6	"math"
7	"slices"
8	"sync"
9	"testing"
10
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/neferclient"
15	neferclientmocks "github.com/bnema/neferclient/mocks"
16)
17
18const (
19	wmBaseGlobal = 5
20	layerGlobal  = 10
21)
22
23var popupGlobals = append(append([]wireGlobal{}, seatGlobals...), wireGlobal{layerGlobal, "zwlr_layer_shell_v1", 4})
24
25// popupLog records every request the peer sees, in order.
26type popupLog struct {
27	mu   sync.Mutex
28	reqs []popupReq
29}
30
31type popupReq struct {
32	obj  uint32
33	op   uint16
34	body []byte
35}
36
37func newPopupLog(srv *wireServer) *popupLog {
38	l := &popupLog{}
39	srv.mu.Lock()
40	srv.hook = func(obj uint32, op uint16, body []byte) {
41		l.mu.Lock()
42		l.reqs = append(l.reqs, popupReq{obj, op, append([]byte(nil), body...)})
43		l.mu.Unlock()
44	}
45	srv.mu.Unlock()
46	return l
47}
48
49func (l *popupLog) len() int {
50	l.mu.Lock()
51	defer l.mu.Unlock()
52	return len(l.reqs)
53}
54
55func (r popupReq) u32(i int) uint32 { return binary.LittleEndian.Uint32(r.body[4*i:]) }
56
57// find returns the index of the first request at or after from that matches.
58func (l *popupLog) find(from int, match func(popupReq) bool) int {
59	l.mu.Lock()
60	defer l.mu.Unlock()
61	for i := from; i < len(l.reqs); i++ {
62		if match(l.reqs[i]) {
63			return i
64		}
65	}
66	return -1
67}
68
69// findLast returns the index of the last request that matches, or -1. Object
70// ids are recycled once the compositor confirms a destruction, so a lookup of
71// a live object must take the newest request.
72func (l *popupLog) findLast(match func(popupReq) bool) int {
73	l.mu.Lock()
74	defer l.mu.Unlock()
75	for i := len(l.reqs) - 1; i >= 0; i-- {
76		if match(l.reqs[i]) {
77			return i
78		}
79	}
80	return -1
81}
82
83// mustFind is find that fails the test when nothing matches.
84func (l *popupLog) mustFind(t *testing.T, from int, match func(popupReq) bool, msg string) int {
85	t.Helper()
86	i := l.find(from, match)
87	require.GreaterOrEqual(t, i, 0, msg)
88	return i
89}
90
91func (l *popupLog) at(i int) popupReq {
92	l.mu.Lock()
93	defer l.mu.Unlock()
94	return l.reqs[i]
95}
96
97func (l *popupLog) all(match func(popupReq) bool) []popupReq {
98	l.mu.Lock()
99	defer l.mu.Unlock()
100	var out []popupReq
101	for _, r := range l.reqs {
102		if match(r) {
103			out = append(out, r)
104		}
105	}
106	return out
107}
108
109func (l *popupLog) op(obj uint32, op uint16) func(popupReq) bool {
110	return func(r popupReq) bool { return r.obj == obj && r.op == op }
111}
112
113// popupEnv is a connection with a layer surface, a toplevel and the request log.
114type popupEnv struct {
115	c     *neferclient.Conn
116	srv   *wireServer
117	log   *popupLog
118	layer *neferclient.Surface
119	top   *neferclient.Surface
120}
121
122func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv {
123	t.Helper()
124	c, srv := connectWire(t, globals, surfaceOutputs)
125	e := &popupEnv{c: c, srv: srv, log: newPopupLog(srv)}
126	var err error
127	e.top, err = c.NewToplevel("parent", 64, 48)
128	require.NoError(t, err)
129	hasLayer := slices.ContainsFunc(globals, func(g wireGlobal) bool { return g.iface == "zwlr_layer_shell_v1" })
130	e.top.MarkPresentable(64, 48) // stands in for the configure round: popups need a configured parent
131	if hasLayer {
132		e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop,
133			Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30})
134		require.NoError(t, err)
135		e.layer.MarkPresentable(200, 30)
136	}
137	require.NoError(t, c.Roundtrip())
138	return e
139}
140
141func (e *popupEnv) wmBase() uint32 {
142	e.srv.mu.Lock()
143	defer e.srv.mu.Unlock()
144	return e.srv.bound[wmBaseGlobal].id
145}
146
147// xdgSurfaceOf returns the xdg_surface id created for wl_surface surf.
148// getPopup returns the newest xdg_surface.get_popup request of xdg.
149func (e *popupEnv) getPopup(t *testing.T, xdg uint32) (int, popupReq) {
150	t.Helper()
151	i := e.log.findLast(e.log.op(xdg, 2))
152	require.GreaterOrEqual(t, i, 0, "xdg_surface.get_popup")
153	return i, e.log.at(i)
154}
155
156func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 {
157	t.Helper()
158	wm := e.wmBase()
159	i := e.log.findLast(func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })
160	require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface")
161	return e.log.at(i).u32(0)
162}
163
164// configurePopup has the peer configure popup p and dispatches it, which
165// makes p a valid parent for further popups.
166func (e *popupEnv) configurePopup(t *testing.T, p *neferclient.Surface) {
167	t.Helper()
168	require.NoError(t, e.c.Roundtrip())
169	xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
170	_, gp := e.getPopup(t, xdg)
171	e.srv.write(frame(gp.u32(0), 0, appendU32(appendU32(appendU32(appendU32(nil, 0), 0), 60), 40)))
172	e.srv.write(frame(xdg, 0, appendU32(nil, 1)))
173	require.NoError(t, e.c.Roundtrip())
174	dispatchUntil(t, e.c, neferclient.NopHandler{}, func() bool { w, _, _ := p.Size(); return w == 60 })
175	require.NoError(t, e.c.Roundtrip()) // the ack_configure reached the peer
176}
177
178// popupID returns the xdg_popup object id of p.
179func (e *popupEnv) popupID(t *testing.T, p *neferclient.Surface) uint32 {
180	t.Helper()
181	_, gp := e.getPopup(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()))
182	return gp.u32(0)
183}
184
185var place = neferclient.PopupPlacement{
186	Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30},
187	Edge:   neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
188	Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY,
189	Offset: [2]int32{3, 4}, Width: 120, Height: 80,
190}
191
192func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) {
193	e := newPopupEnv(t, popupGlobals)
194	n := e.log.len()
195	p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 42})
196	require.NoError(t, err)
197	require.NoError(t, e.c.Roundtrip())
198
199	popupSurf := p.SurfaceObjectID()
200	xdg := e.xdgSurfaceOf(t, popupSurf)
201	getPopup, gp := e.getPopup(t, xdg)
202	popupID, positioner := gp.u32(0), gp.u32(2)
203	require.Zero(t, gp.u32(1), "layer parent: null xdg parent")
204
205	// The layer surface is the one created for e.layer.
206	layerID := e.log.at(e.log.mustFind(t, 0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() }, "get_layer_surface")).u32(0)
207	layerPopup := e.log.mustFind(t, n, e.log.op(layerID, 5), "layer_surface.get_popup")
208	require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup")
209	require.Equal(t, popupID, e.log.at(layerPopup).u32(0))
210
211	grab := e.log.mustFind(t, n, e.log.op(popupID, 1), "xdg_popup.grab")
212	require.Greater(t, grab, layerPopup, "grab follows get_popup")
213	require.Equal(t, uint32(42), e.log.at(grab).u32(1))
214	require.NotZero(t, e.log.at(grab).u32(0), "seat")
215	commit := e.log.mustFind(t, n, e.log.op(popupSurf, 6), "wl_surface.commit")
216	require.Greater(t, commit, grab, "grab precedes the first commit")
217
218	// Positioner state.
219	pos := func(op uint16) popupReq {
220		i := e.log.mustFind(t, n, e.log.op(positioner, op), "positioner request")
221		require.Less(t, i, getPopup)
222		return e.log.at(i)
223	}
224	require.Equal(t, []uint32{120, 80}, []uint32{pos(1).u32(0), pos(1).u32(1)})
225	require.Equal(t, []uint32{10, 0, 20, 30}, []uint32{pos(2).u32(0), pos(2).u32(1), pos(2).u32(2), pos(2).u32(3)})
226	require.Equal(t, uint32(neferclient.EdgeBottom), pos(3).u32(0))
227	require.Equal(t, uint32(neferclient.EdgeBottomRight), pos(4).u32(0))
228	require.Equal(t, uint32(1|8|32), pos(5).u32(0))
229	require.Equal(t, []uint32{3, 4}, []uint32{pos(6).u32(0), pos(6).u32(1)})
230	require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed")
231}
232
233func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) {
234	e := newPopupEnv(t, popupGlobals)
235	n := e.log.len()
236	p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
237	require.NoError(t, err)
238	require.NoError(t, e.c.Roundtrip())
239	parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID())
240	xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
241	_, gp := e.getPopup(t, xdg)
242	require.Equal(t, parentXdg, gp.u32(1))
243	popupID := gp.u32(0)
244	require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested")
245}
246
247func TestPopupNestedOnPopup(t *testing.T) {
248	e := newPopupEnv(t, popupGlobals)
249	p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
250	require.NoError(t, err)
251	_, err = e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
252	require.ErrorContains(t, err, "not configured yet", "a popup parent is mapped only once configured")
253	e.configurePopup(t, p)
254	child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
255	require.NoError(t, err)
256	require.NoError(t, e.c.Roundtrip())
257	_, gp := e.getPopup(t, e.xdgSurfaceOf(t, child.SurfaceObjectID()))
258	require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), gp.u32(1))
259	require.Error(t, p.Close(), "popup with an open child")
260	require.NoError(t, child.Close())
261	require.NoError(t, p.Close())
262}
263
264func TestPopupConfigureAcksAndDone(t *testing.T) {
265	e := newPopupEnv(t, popupGlobals)
266	p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
267	require.NoError(t, err)
268	require.NoError(t, e.c.Roundtrip())
269	xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
270	_, gp := e.getPopup(t, xdg)
271	popupID := gp.u32(0)
272
273	var w, h int32
274	var done bool
275	hd := neferclientmocks.NewMockHandler(t)
276	hd.EXPECT().Configure(p.ID(), mock.Anything, mock.Anything).Run(func(_ neferclient.SurfaceID, cw, ch int32) { w, h = cw, ch }).Return().Once()
277	hd.EXPECT().PopupDone(p.ID()).Run(func(neferclient.SurfaceID) { done = true }).Return().Once()
278
279	cfg := appendU32(appendU32(appendU32(appendU32(nil, 5), 6), 120), 80)
280	e.srv.write(frame(popupID, 0, cfg))           // xdg_popup.configure(5,6,120,80)
281	e.srv.write(frame(xdg, 0, appendU32(nil, 9))) // xdg_surface.configure(9)
282	require.NoError(t, e.c.Roundtrip())
283	dispatchUntil(t, e.c, hd, func() bool { return w != 0 })
284	require.Equal(t, [2]int32{120, 80}, [2]int32{w, h})
285	require.NoError(t, e.c.Roundtrip())
286	ack := e.log.mustFind(t, 0, e.log.op(xdg, 4), "ack_configure")
287	require.Equal(t, uint32(9), e.log.at(ack).u32(0))
288	gw, gh, _ := p.Size()
289	require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh})
290
291	e.srv.write(frame(popupID, 1, nil)) // popup_done
292	require.NoError(t, e.c.Roundtrip())
293	dispatchUntil(t, e.c, hd, func() bool { return done })
294	require.NoError(t, p.Close())
295	require.NoError(t, e.c.Roundtrip())
296	destroyPopup := e.log.mustFind(t, 0, e.log.op(popupID, 0), "xdg_popup.destroy")
297	destroyXdg := e.log.mustFind(t, 0, e.log.op(xdg, 0), "xdg_surface.destroy")
298	require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface")
299}
300
301func TestPopupReposition(t *testing.T) {
302	e := newPopupEnv(t, popupGlobals)
303	p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
304	require.NoError(t, err)
305	require.NoError(t, e.c.Roundtrip())
306	xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
307	_, gp := e.getPopup(t, xdg)
308	popupID := gp.u32(0)
309
310	np := place
311	np.Anchor.X, np.Width = 50, 90
312	n := e.log.len()
313	require.NoError(t, p.Reposition(np))
314	require.NoError(t, e.c.Roundtrip())
315	i := e.log.mustFind(t, n, e.log.op(popupID, 2), "xdg_popup.reposition")
316	positioner := e.log.at(i).u32(0)
317	token := e.log.at(i).u32(1)
318	setSize := e.log.at(e.log.mustFind(t, n, e.log.op(positioner, 1), "positioner.set_size"))
319	require.Equal(t, uint32(90), setSize.u32(0))
320	require.Less(t, e.log.mustFind(t, n, e.log.op(positioner, 1), "set_size"), i)
321	require.Greater(t, e.log.mustFind(t, i, e.log.op(positioner, 0), "positioner.destroy"), i)
322
323	n = e.log.len()
324	require.NoError(t, p.Reposition(np))
325	require.NoError(t, e.c.Roundtrip())
326	second := e.log.at(e.log.mustFind(t, n, e.log.op(popupID, 2), "second xdg_popup.reposition"))
327	require.NotEqual(t, token, second.u32(1), "each reposition carries its own token")
328
329	bad := np
330	bad.Width = 0
331	require.Error(t, p.Reposition(bad))
332	require.Error(t, e.top.Reposition(np), "not a popup")
333}
334
335func TestPopupRepositionNeedsXdgV3(t *testing.T) {
336	old := append([]wireGlobal{}, popupGlobals...)
337	old[wmBaseGlobal-1].version = 2
338	require.Equal(t, "xdg_wm_base", old[wmBaseGlobal-1].iface)
339	e := newPopupEnv(t, old)
340	p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
341	require.NoError(t, err)
342	require.NoError(t, e.c.Roundtrip())
343	n := e.log.len()
344	var ce *neferclient.CapabilityError
345	require.ErrorAs(t, p.Reposition(place), &ce)
346	require.NoError(t, e.c.Roundtrip())
347	require.Equal(t, n, e.log.len(), "nothing sent")
348}
349
350func TestPopupInvalidConfigsSendNothing(t *testing.T) {
351	e := newPopupEnv(t, surfaceGlobals) // no seat, no layer shell
352	closed, err := e.c.NewToplevel("closed", 64, 48)
353	require.NoError(t, err)
354	require.NoError(t, closed.Close())
355	unconfigured, err := e.c.NewToplevel("unconfigured", 64, 48)
356	require.NoError(t, err)
357	l, err := e.c.Lock()
358	require.NoError(t, err)
359	lockSurf, err := l.NewSurface(7)
360	require.NoError(t, err)
361	lockSurf.MarkPresentable(64, 48)
362	other, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
363	foreign, err := other.NewToplevel("foreign", 64, 48)
364	require.NoError(t, err)
365	foreign.MarkPresentable(64, 48)
366	require.NoError(t, e.c.Roundtrip())
367	n := e.log.len()
368
369	with := func(f func(*neferclient.PopupConfig)) neferclient.PopupConfig {
370		cfg := neferclient.PopupConfig{Parent: e.top, Place: place}
371		f(&cfg)
372		return cfg
373	}
374	var capErr *neferclient.CapabilityError
375	for name, tc := range map[string]struct {
376		cfg  neferclient.PopupConfig
377		want func(error)
378	}{
379		"nil parent":      {with(func(c *neferclient.PopupConfig) { c.Parent = nil }), func(err error) { require.ErrorContains(t, err, "needs a parent surface") }},
380		"foreign parent":  {with(func(c *neferclient.PopupConfig) { c.Parent = foreign }), func(err error) { require.ErrorContains(t, err, "of this connection") }},
381		"closed parent":   {with(func(c *neferclient.PopupConfig) { c.Parent = closed }), func(err error) { require.ErrorIs(t, err, neferclient.ErrClosed) }},
382		"lock parent":     {with(func(c *neferclient.PopupConfig) { c.Parent = lockSurf }), func(err error) { require.ErrorContains(t, err, "must be a layer surface, a toplevel or a popup") }},
383		"unconfigured":    {with(func(c *neferclient.PopupConfig) { c.Parent = unconfigured }), func(err error) { require.ErrorContains(t, err, "not configured yet") }},
384		"zero width":      {with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
385		"negative height": {with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
386		"empty anchor":    {with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }), func(err error) { require.ErrorContains(t, err, "anchor rectangle") }},
387		"anchor overflow": {with(func(c *neferclient.PopupConfig) {
388			c.Place.Anchor = neferclient.Rect{X: math.MaxInt32, Width: 10, Height: 5}
389		}), func(err error) { require.ErrorContains(t, err, "overflows") }},
390		"bad edge":       {with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
391		"bad gravity":    {with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
392		"bad adjust":     {with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }), func(err error) { require.ErrorContains(t, err, "constraint adjustment") }},
393		"grab no serial": {with(func(c *neferclient.PopupConfig) { c.Grab = true }), func(err error) { require.ErrorContains(t, err, "serial") }},
394		"grab no seat":   {with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 }), func(err error) { require.ErrorAs(t, err, &capErr) }},
395	} {
396		_, err := e.c.NewPopup(tc.cfg)
397		require.Error(t, err, name)
398		tc.want(err)
399	}
400	require.NoError(t, e.c.Roundtrip())
401	require.Equal(t, n, e.log.len(), "no request was sent")
402	require.NoError(t, e.top.Close(), "failed popups left no child behind")
403}
404
405// A failed seat bind is the cause of the grab's CapabilityError.
406func TestPopupGrabReportsSeatBindError(t *testing.T) {
407	e := newPopupEnv(t, surfaceGlobals)
408	bindErr := errors.New("bind refused")
409	e.c.SetSeatErr(bindErr)
410	_, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place, Grab: true, Serial: 1})
411	var ce *neferclient.CapabilityError
412	require.ErrorAs(t, err, &ce)
413	require.Equal(t, "wl_seat", ce.Name)
414	require.ErrorIs(t, err, bindErr)
415}
416
417// A grab needs a grabbing parent popup, and while a grab chain is open only
418// its topmost popup can be the parent of the next grab. Refusals send nothing.
419func TestPopupGrabChain(t *testing.T) {
420	e := newPopupEnv(t, popupGlobals)
421	grab := func(parent *neferclient.Surface) (*neferclient.Surface, error) {
422		return e.c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: place, Grab: true, Serial: 7})
423	}
424	refused := func(parent *neferclient.Surface, want string) {
425		t.Helper()
426		require.NoError(t, e.c.Roundtrip()) // earlier requests are logged
427		n := e.log.len()
428		_, err := grab(parent)
429		require.ErrorContains(t, err, want)
430		require.NoError(t, e.c.Roundtrip())
431		require.Equal(t, n, e.log.len(), "no request was sent")
432	}
433
434	plain, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
435	require.NoError(t, err)
436	e.configurePopup(t, plain)
437	refused(plain, "needs a grabbing parent popup")
438
439	a, err := grab(e.layer)
440	require.NoError(t, err)
441	e.configurePopup(t, a)
442	refused(e.top, "topmost grabbing popup")
443	refused(e.layer, "topmost grabbing popup")
444	refused(plain, "needs a grabbing parent popup")
445
446	b, err := grab(a)
447	require.NoError(t, err)
448	e.configurePopup(t, b)
449	refused(a, "topmost grabbing popup")
450
451	// Closing the topmost popup returns the grab to its parent.
452	require.NoError(t, b.Close())
453	c2, err := grab(a)
454	require.NoError(t, err)
455	require.NoError(t, c2.Close())
456	require.NoError(t, a.Close())
457
458	// A popup_done ends the grab of the dismissed popup, too.
459	a2, err := grab(e.layer)
460	require.NoError(t, err)
461	refused(e.top, "topmost grabbing popup")
462	e.srv.write(frame(e.popupID(t, a2), 1, nil))
463	require.NoError(t, e.c.Roundtrip())
464	var done []neferclient.SurfaceID
465	hd := neferclientmocks.NewMockHandler(t)
466	hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Once()
467	dispatchUntil(t, e.c, hd, func() bool { return len(done) > 0 })
468	require.Equal(t, []neferclient.SurfaceID{a2.ID()}, done)
469	top, err := grab(e.top)
470	require.NoError(t, err, "the chain is closed after popup_done")
471	require.NoError(t, top.Close())
472	require.NoError(t, a2.Close())
473	require.NoError(t, plain.Close())
474}

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