v0.3.0 6800c6a

connection_test.go RawHistoryFolder

1//go:build linux
2
3package wayland
4
5import (
6	"context"
7	"encoding/binary"
8	"errors"
9	"fmt"
10	"github.com/bnema/wlturbo/protocol/linuxdmabuf"
11	"golang.org/x/sys/unix"
12	"net"
13	"os"
14	"strings"
15	"syscall"
16	"testing"
17	"time"
18
19	"github.com/bnema/wlturbo"
20	"github.com/bnema/wlturbo/protocol/core"
21	"github.com/bnema/wlturbo/protocol/cursorshape"
22)
23
24func TestSharedConnectionKeyboardRoutesOnlyFocusedWindow(t *testing.T) {
25	ca, server := inputPair(t)
26	d, err := wlturbo.ConnectFromConn(ca)
27	if err != nil {
28		t.Fatal(err)
29	}
30	defer d.Close()
31	inputRequest(t, server)
32	c := &Connection{Display: d, events: make(chan Event, 32), stop: make(chan struct{}), windows: make(map[uint32]*Window)}
33	host := &Window{connection: c, seatVersion: 9}
34	seat := core.NewSeat(d.Context())
35	if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil {
36		t.Fatal(err)
37	}
38	inputRequest(t, server)
39	keyboard, err := seat.GetKeyboard()
40	if err != nil {
41		t.Fatal(err)
42	}
43	inputRequest(t, server)
44	first, second := &Window{connection: c}, &Window{connection: c}
45	c.routes.Store(uint32(30), first)
46	c.routes.Store(uint32(31), second)
47	host.watchSharedKeyboard(keyboard)
48	// enter includes a length-prefixed empty key array, then key down.
49	for _, msg := range [][]byte{inputFrame(keyboard.ID(), 1, 1, 30, 0), inputFrame(keyboard.ID(), 3, 2, 0, 30, 1), inputFrame(keyboard.ID(), 2, 3, 30), inputFrame(keyboard.ID(), 1, 4, 31, 0), inputFrame(keyboard.ID(), 3, 5, 0, 48, 1)} {
50		inputSend(t, server, msg)
51		if err := d.Dispatch(); err != nil {
52			t.Fatal(err)
53		}
54	}
55	var keys []Event
56	for len(c.events) > 0 {
57		ev := <-c.events
58		if ev.Kind == InputKey {
59			keys = append(keys, ev)
60		}
61	}
62	if len(keys) != 2 || keys[0].Window != first || keys[0].Code != 30 || keys[1].Window != second || keys[1].Code != 48 {
63		t.Fatalf("keys: %+v", keys)
64	}
65}
66
67func TestHarnessConnectionLock(t *testing.T) {
68	out, _ := runLayerHarness(t, "TestHarnessConnectionLockClient")
69	for _, want := range []string{"LOCK acquired", "LOCK exact configure", "LOCK shared display", "LOCK sibling survived", "LOCK unlock", "LOCK manager reused", "LOCK named layer sibling"} {
70		if !strings.Contains(out, want) {
71			t.Fatalf("missing %q in %s", want, out)
72		}
73	}
74}
75func TestHarnessConnectionLockClient(t *testing.T) {
76	harnessChild(t)
77	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
78	defer cancel()
79	c, err := ConnectConnection(ctx, "")
80	if err != nil {
81		t.Fatal(err)
82	}
83	defer c.Close()
84	outputs := c.Outputs()
85	if len(outputs) != 1 {
86		t.Fatalf("outputs %+v", outputs)
87	}
88	if err := c.AcquireLock(); err != nil {
89		t.Fatal(err)
90	}
91	events := c.StartReader()
92	wait := func(want EventKind) {
93		t.Helper()
94		for {
95			select {
96			case ev := <-events:
97				if err := c.Apply(ev); err != nil {
98					t.Fatal(err)
99				}
100				if ev.Kind == want {
101					return
102				}
103			case <-ctx.Done():
104				t.Fatal("event timeout")
105			}
106		}
107	}
108	wait(LockAcquired)
109	fmt.Println("LOCK acquired")
110	first, err := c.NewWindow(ctx, 1, 1, false, SurfaceOptions{Lock: &LockOptions{Output: outputs[0].Global}})
111	if err != nil {
112		t.Fatal(err)
113	}
114	for !first.Configured || !first.FeedbackDone || first.MainDevice == 0 || len(first.Tranches) == 0 {
115		select {
116		case ev := <-events:
117			if err := c.Apply(ev); err != nil {
118				t.Fatal(err)
119			}
120		case <-ctx.Done():
121			t.Fatal("lock configure timeout")
122		}
123	}
124	if first.Width != 400 || first.Height != 300 || first.LockSurface == nil || first.XdgSurface != nil || first.LayerSurface != nil {
125		t.Fatalf("lock geometry/roles: %dx%d", first.Width, first.Height)
126	}
127	fmt.Println("LOCK exact configure")
128	// An ordinary sibling shares the transport, but cannot acquire another lock.
129	second, err := c.NewWindow(ctx, 20, 20, false, SurfaceOptions{})
130	if err != nil {
131		t.Fatal(err)
132	}
133	if first.Display != second.Display || first.Display != c.Display || first.Seat != nil {
134		t.Fatal("independent display or lock exposed clipboard/IME seat")
135	}
136	fmt.Println("LOCK shared display")
137	if err := first.Close(); err != nil {
138		t.Fatal(err)
139	}
140	if second.Display == nil || c.closed {
141		t.Fatal("window close destroyed shared connection")
142	}
143	if err := second.SetTitle("sibling survives"); err != nil {
144		t.Fatal(err)
145	}
146	fmt.Println("LOCK sibling survived")
147	if err := c.UnlockAndDestroy(); err != nil {
148		t.Fatal(err)
149	}
150	fmt.Println("LOCK unlock")
151	manager := c.lockManager
152	for range 2 {
153		if err := c.AcquireLock(); err != nil {
154			t.Fatal(err)
155		}
156		wait(LockAcquired)
157		if c.lockManager != manager {
158			t.Fatal("lock manager leaked across cycles")
159		}
160		if err := c.UnlockAndDestroy(); err != nil {
161			t.Fatal(err)
162		}
163	}
164	fmt.Println("LOCK manager reused")
165	layer, err := c.NewWindow(ctx, 20, 20, true, SurfaceOptions{Layer: &LayerOptions{Output: outputs[0].Name, Layer: LayerTop, Namespace: "named-sibling"}})
166	if err != nil {
167		t.Fatal(err)
168	}
169	for !layer.Configured || !layer.FeedbackDone {
170		select {
171		case ev := <-events:
172			if err := c.Apply(ev); err != nil {
173				t.Fatal(err)
174			}
175		case <-ctx.Done():
176			t.Fatal("named sibling timeout")
177		}
178	}
179	if layer.layerOutputGlobal != outputs[0].Global {
180		t.Fatal("named sibling selected wrong output")
181	}
182	fmt.Println("LOCK named layer sibling")
183}
184
185func TestOwnerFocusResetWithFullReaderQueue(t *testing.T) {
186	c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{1: {}}}
187	for range cap(c.events) {
188		c.events <- Event{Kind: LockAcquired}
189	}
190	done := make(chan error, 1)
191	go func() { done <- c.Apply(Event{Kind: InputFocusOut}) }()
192	select {
193	case err := <-done:
194		if err != nil {
195			t.Fatal(err)
196		}
197	case <-time.After(time.Second):
198		close(c.stop)
199		t.Fatal("owner reset self-posted into full reader queue")
200	}
201	if len(c.events) != cap(c.events) {
202		t.Fatal("owner reset consumed reader events")
203	}
204	inputs := c.windows[1].DrainInput(nil)
205	if len(inputs) != 1 || inputs[0].Kind != "focus-out" {
206		t.Fatalf("inputs %+v", inputs)
207	}
208}
209
210func TestKeyboardMapCacheRetiresEveryGeneration(t *testing.T) {
211	for range 4 {
212		f, err := os.CreateTemp(t.TempDir(), "keymap")
213		if err != nil {
214			t.Fatal(err)
215		}
216		fd, err := syscall.Dup(int(f.Fd()))
217		if err != nil {
218			t.Fatal(err)
219		}
220		f.Close()
221		cache := &keyboardMapCache{fd: -1}
222		cache.replace(fd)
223		duplicate, err := cache.duplicate()
224		if err != nil || duplicate < 0 {
225			t.Fatalf("duplicate %d %v", duplicate, err)
226		}
227		if flags, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil || flags&unix.FD_CLOEXEC == 0 {
228			t.Fatalf("delivered keymap duplicate is inheritable: flags %d err %v", flags, err)
229		}
230		cache.close()
231		cache.close()
232		if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, syscall.EBADF) {
233			t.Fatalf("retired map FD still open: %v", err)
234		}
235		if _, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil {
236			t.Fatal("retired source closed delivered duplicate")
237		}
238		syscall.Close(duplicate)
239		if got, err := cache.duplicate(); got != -1 || err != nil {
240			t.Fatal("retired callback duplicated map")
241		}
242	}
243}
244
245func TestFeedbackReadinessWaitsForCompleteEvent(t *testing.T) {
246	w := &Window{Configured: true}
247	if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 1}); err != nil {
248		t.Fatal(err)
249	}
250	if err := w.Apply(Event{Kind: FeedbackTranche, Tranche: []linuxdmabuf.FormatEntry{{}}}); err != nil {
251		t.Fatal(err)
252	}
253	if w.FeedbackDone {
254		t.Fatal("partial feedback treated as complete")
255	}
256	if err := w.Apply(Event{Kind: FeedbackFormats, Formats: []linuxdmabuf.FormatEntry{{}}}); err != nil {
257		t.Fatal(err)
258	}
259	if w.FeedbackDone {
260		t.Fatal("table alone treated as complete")
261	}
262	if err := w.Apply(Event{Kind: FeedbackComplete}); err != nil || !w.FeedbackDone {
263		t.Fatal("done not applied")
264	}
265}
266
267// sharedSeat builds a Connection whose seat host is bound over a socketpair.
268func sharedSeat(t *testing.T) (*Connection, *wlturbo.Display, *net.UnixConn) {
269	t.Helper()
270	ca, server := inputPair(t)
271	d, err := wlturbo.ConnectFromConn(ca)
272	if err != nil {
273		t.Fatal(err)
274	}
275	t.Cleanup(func() { _ = d.Close() })
276	inputRequest(t, server)
277	c := &Connection{Display: d, events: make(chan Event, 256), stop: make(chan struct{}), windows: make(map[uint32]*Window)}
278	c.seat = &Window{Display: d, connection: c, seatVersion: 9}
279	c.seat.Seat = core.NewSeat(d.Context())
280	if err := d.Registry().Bind(1, core.SeatInterface, 9, c.seat.Seat); err != nil {
281		t.Fatal(err)
282	}
283	inputRequest(t, server)
284	return c, d, server
285}
286
287func fdOpen(fd int) bool {
288	_, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
289	return err == nil
290}
291
292func TestKeyboardGenerationKeymapClosedExactlyOnce(t *testing.T) {
293	c, d, server := sharedSeat(t)
294	for generation := range 3 {
295		if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 2}); err != nil {
296			t.Fatal(err)
297		}
298		inputRequest(t, server) // get_keyboard
299		cache := c.seat.keymapCache
300		if cache == nil || c.seat.WLKeyboard == nil {
301			t.Fatalf("generation %d: no keyboard cache", generation)
302		}
303		f, err := os.CreateTemp(t.TempDir(), "keymap")
304		if err != nil {
305			t.Fatal(err)
306		}
307		var taken []int
308		for range 2 { // a second keymap in one generation replaces the first
309			inputSend(t, server, inputFrame(c.seat.WLKeyboard.ID(), 0, 1, 4), int(f.Fd()))
310			if err = d.Dispatch(); err != nil {
311				t.Fatal(err)
312			}
313			cache.mu.Lock()
314			taken = append(taken, cache.fd)
315			cache.mu.Unlock()
316		}
317		f.Close()
318		if taken[0] == taken[1] || fdOpen(taken[0]) {
319			t.Fatalf("replaced keymap FD %d not closed", taken[0])
320		}
321		if !fdOpen(taken[1]) {
322			t.Fatalf("generation %d: live keymap FD closed", generation)
323		}
324		if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil {
325			t.Fatal(err)
326		}
327		inputRequest(t, server) // release
328		if fdOpen(taken[1]) {
329			t.Fatalf("generation %d: keymap FD %d leaked after capability loss", generation, taken[1])
330		}
331		if c.seat.keymapCache != nil || c.seat.WLKeyboard != nil {
332			t.Fatal("keyboard generation not retired")
333		}
334		cache.close() // a retired cache closes nothing twice
335	}
336}
337
338func TestPointerReplacementResetsSiblingCursor(t *testing.T) {
339	c, d, server := sharedSeat(t)
340	ctx := d.Context()
341	shapes := cursorshape.NewWpCursorShapeManager(ctx)
342	if err := d.Registry().Bind(2, cursorshape.WpCursorShapeManagerInterface, 1, shapes); err != nil {
343		t.Fatal(err)
344	}
345	inputRequest(t, server)
346	sibling := &Window{connection: c, CursorShapes: shapes, seatVersion: 9}
347	c.windows[1] = sibling
348	if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil {
349		t.Fatal(err)
350	}
351	if id, op, _ := readRequest(t, server); id != c.seat.Seat.ID() || op != 0 {
352		t.Fatalf("get_pointer %d %d", id, op)
353	}
354	first := sibling.Pointer
355	if first == nil || sibling.pointerGen != 1 {
356		t.Fatalf("sibling pointer/gen %v %d", first, sibling.pointerGen)
357	}
358	sibling.PointerEntered(7, 1)
359	if err := sibling.SetCursor(CursorPointer); err != nil {
360		t.Fatal(err)
361	}
362	_, _, body := readRequest(t, server) // shape device
363	device := binary.LittleEndian.Uint32(body)
364	readRequest(t, server) // set_shape
365	// Pointer capability loss: device destroyed before the pointer, serial gone.
366	if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil {
367		t.Fatal(err)
368	}
369	if id, op, _ := readRequest(t, server); id != device || op != 0 {
370		t.Fatalf("shape device not destroyed first: %d %d", id, op)
371	}
372	if id, _, _ := readRequest(t, server); id != first.ID() {
373		t.Fatalf("pointer not released after device: %d", id)
374	}
375	if sibling.Pointer != nil || sibling.cursor.serial != 0 || sibling.cursor.device != nil {
376		t.Fatal("sibling kept stale pointer state")
377	}
378	// Replacement: a late enter of the released generation is ignored.
379	if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil {
380		t.Fatal(err)
381	}
382	readRequest(t, server) // get_pointer
383	if sibling.pointerGen != 2 || sibling.Pointer == nil || sibling.Pointer == first {
384		t.Fatalf("replacement not adopted: gen %d", sibling.pointerGen)
385	}
386	sibling.PointerEntered(9, 1)
387	if err := sibling.SetCursor(CursorPointer); err != nil {
388		t.Fatal(err)
389	}
390	noRequest(t, server)
391	sibling.PointerEntered(11, 2)
392	if err := sibling.SetCursor(CursorPointer); err != nil {
393		t.Fatal(err)
394	}
395	_, _, body = readRequest(t, server)
396	if binary.LittleEndian.Uint32(body[4:]) != sibling.Pointer.ID() {
397		t.Fatal("shape device bound to a stale pointer")
398	}
399	if _, _, body = readRequest(t, server); binary.LittleEndian.Uint32(body) != 11 {
400		t.Fatalf("set_shape used stale serial: %x", body)
401	}
402}
403
404func TestOwnerSeatRemovalWithFullReaderQueue(t *testing.T) {
405	c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{}}
406	c.seat = &Window{connection: c}
407	lock := &Window{LockSurface: nil, rawInput: []Event{{Kind: InputKey}}}
408	c.windows[1], c.windows[2] = &Window{}, lock
409	for range cap(c.events) {
410		c.events <- Event{Kind: LockAcquired}
411	}
412	done := make(chan error, 1)
413	go func() { done <- c.Apply(Event{Kind: SeatRemoved}) }()
414	select {
415	case err := <-done:
416		if err != nil {
417			t.Fatal(err)
418		}
419	case <-time.After(time.Second):
420		close(c.stop)
421		t.Fatal("seat removal blocked on the full reader queue")
422	}
423	if len(c.events) != cap(c.events) {
424		t.Fatal("owner apply consumed or added reader events")
425	}
426}
427
428func TestReaderPauseNeverBlocksOnFullQueueAndResumes(t *testing.T) {
429	ca, server := inputPair(t)
430	d, err := wlturbo.ConnectFromConn(ca)
431	if err != nil {
432		t.Fatal(err)
433	}
434	inputRequest(t, server)
435	c := &Connection{Display: d, socket: ca, events: make(chan Event, 1), stop: make(chan struct{}), windows: map[uint32]*Window{}, pauseSig: make(chan struct{}), pauseAck: make(chan struct{}, 1), resume: make(chan struct{}, 1)}
436	seat := core.NewSeat(d.Context())
437	if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil {
438		t.Fatal(err)
439	}
440	inputRequest(t, server)
441	seat.OnCapabilities(func(caps uint32) { c.post(Event{Kind: SeatCapabilities, Capabilities: caps}) })
442	c.StartReader()
443	defer func() { _ = c.Close() }()
444	// Fill the one-slot queue, then a second event blocks the reader in post.
445	inputSend(t, server, inputFrame(seat.ID(), 0, 1))
446	inputSend(t, server, inputFrame(seat.ID(), 0, 2))
447	time.Sleep(50 * time.Millisecond)
448	paused := make(chan func(), 1)
449	go func() { paused <- c.pauseReader() }()
450	var resume func()
451	select {
452	case resume = <-paused:
453	case <-time.After(2 * time.Second):
454		t.Fatal("pause deadlocked against the full reader queue")
455	}
456	if first := <-c.events; first.Capabilities != 1 {
457		t.Fatalf("first event %+v", first)
458	}
459	resume()
460	select {
461	case ev := <-c.events:
462		if ev.Capabilities != 2 {
463			t.Fatalf("parked event lost or reordered: %+v", ev)
464		}
465	case <-time.After(2 * time.Second):
466		t.Fatal("parked event not re-posted after resume")
467	}
468	// A pause with an idle blocked read is interrupted too, and reading resumes.
469	resume2 := c.pauseReader()
470	resume2()
471	inputSend(t, server, inputFrame(seat.ID(), 0, 4))
472	select {
473	case ev := <-c.events:
474		if ev.Capabilities != 4 {
475			t.Fatalf("event after resume %+v", ev)
476		}
477	case <-time.After(2 * time.Second):
478		t.Fatal("reader did not resume")
479	}
480}
481
482func TestReadyRequiresConfigureFeedbackAndFrame(t *testing.T) {
483	w := &Window{Configured: true, FrameReady: true}
484	if w.Ready() {
485		t.Fatal("ready before dmabuf feedback completed")
486	}
487	w.FeedbackDone = true
488	if !w.Ready() {
489		t.Fatal("not ready")
490	}
491	// A later feedback round invalidates readiness until its own done.
492	if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 2}); err != nil {
493		t.Fatal(err)
494	}
495	if w.Ready() || len(w.Tranches) != 0 {
496		t.Fatal("stale feedback kept across rounds")
497	}
498	if err := w.Present(nil, nil, nil, 0, 0); err == nil {
499		t.Fatal("present without buffer accepted")
500	}
501}
502
503func TestDrainStartupHoldsLockAndOutputEventsInOrder(t *testing.T) {
504	c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}, outputs: map[uint32]Output{}}
505	c.events <- Event{Kind: OutputAdded, Output: Output{Global: 3}}
506	c.events <- Event{Kind: LockAcquired}
507	c.events <- Event{Kind: OutputRemoved, Output: Output{Global: 3}}
508	if err := c.drainStartup(true); err != nil {
509		t.Fatal(err)
510	}
511	if c.Locked() || c.lockDone {
512		t.Fatal("startup drain applied `locked`, hiding it from the owner")
513	}
514	held := c.TakeHeld()
515	if len(held) != 3 || held[0].Kind != OutputAdded || held[1].Kind != LockAcquired || held[2].Kind != OutputRemoved {
516		t.Fatalf("held %+v", held)
517	}
518	if len(c.TakeHeld()) != 0 {
519		t.Fatal("held events delivered twice")
520	}
521	// The owner applies them itself; unlock then becomes possible.
522	for _, ev := range held {
523		if err := c.Apply(ev); err != nil {
524			t.Fatal(err)
525		}
526	}
527	if !c.Locked() {
528		t.Fatal("locked not recorded once the owner applied it")
529	}
530}
531
532func TestDrainStartupFinishedWrapsSentinelAndKeepsEvent(t *testing.T) {
533	c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}}
534	c.events <- Event{Kind: LockFinished}
535	err := c.drainStartup(true)
536	if !errors.Is(err, ErrLockFinished) {
537		t.Fatalf("err %v", err)
538	}
539	if held := c.TakeHeld(); len(held) != 1 || held[0].Kind != LockFinished {
540		t.Fatalf("finished event lost: %+v", held)
541	}
542	// Window-tagged and ordinary events are still applied during setup.
543	c.events <- Event{Kind: BufferRelease, BufferID: 1}
544	if err := c.drainStartup(true); err == nil || errors.Is(err, ErrLockFinished) {
545		t.Logf("non-lock event handled: %v", err)
546	}
547}
548
549// A closing session must never read the shared queue: it carries other
550// windows' releases, lock state, output changes and transport errors.
551func TestSharedWindowCloseLeavesReaderQueueAlone(t *testing.T) {
552	c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}}
553	closing, other := &Window{connection: c, events: c.events}, &Window{connection: c, events: c.events}
554	if closing.ReaderEvents() != nil {
555		t.Fatal("shared window exposes the connection queue to its session")
556	}
557	if (&Window{events: make(chan Event)}).ReaderEvents() == nil {
558		t.Fatal("standalone window lost its own queue")
559	}
560	queued := []Event{
561		{Kind: BufferRelease, BufferID: 1, Window: other},
562		{Kind: LockAcquired},
563		{Kind: OutputRemoved, Output: Output{Global: 9}},
564		{Kind: TransportError, Err: errors.New("gone")},
565		{Kind: InputKey, Code: 30, Window: other},
566	}
567	for _, ev := range queued {
568		c.events <- ev
569	}
570	if err := closing.Close(); err != nil {
571		t.Fatal(err)
572	}
573	if len(c.events) != len(queued) {
574		t.Fatalf("close consumed %d shared events", len(queued)-len(c.events))
575	}
576	for i, want := range queued {
577		if got := <-c.events; got.Kind != want.Kind || got.Window != want.Window {
578			t.Fatalf("event %d reordered: %+v", i, got)
579		}
580	}
581}

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 12

↑↓ moveenter openesc close