session_lock_lifecycle_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"slices"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/bnema/go-wayland-bindings/server/drmlease"
11	serverlock "github.com/bnema/go-wayland-bindings/server/extsessionlock"
12	"github.com/bnema/go-wayland-bindings/server/wayland"
13	"github.com/bnema/neferwl/internal/adapters/sessionsecurity"
14	"github.com/bnema/neferwl/internal/logging"
15	"github.com/bnema/neferwl/internal/ports"
16	"github.com/bnema/wlturbo"
17	"github.com/bnema/wlturbo/protocol/core"
18	lockclient "github.com/bnema/wlturbo/protocol/extsessionlock"
19	"golang.org/x/sys/unix"
20)
21
22// A real display with capture completion wiring established before Run starts.
23func lifecycleLockServer(t *testing.T, leaseRequests ...chan ports.LeaseMessage) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.CaptureDone, string) {
24	t.Helper()
25	gate := &sessionsecurity.Gate{}
26	changes := make(chan ports.SecurityState, 16)
27	captured := make(chan ports.CaptureDone, 16)
28	dir := t.TempDir()
29	channels := Channels{SecurityChanges: changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captured: captured}
30	if len(leaseRequests) != 0 {
31		channels.LeaseRequests = leaseRequests[0]
32	}
33	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, channels, logging.For(context.Background(), "wayland"))
34	if err != nil {
35		t.Fatal(err)
36	}
37	ctx, cancel := context.WithCancel(context.Background())
38	done := make(chan error, 1)
39	go func() { done <- s.Run(ctx) }()
40	t.Cleanup(func() {
41		cancel()
42		select {
43		case err := <-done:
44			if err != nil {
45				t.Error(err)
46			}
47		case <-time.After(3 * time.Second):
48			t.Error("lock lifecycle server did not stop")
49		}
50	})
51	return s, gate, changes, captured, dir
52}
53
54func lifecycleTransition(t *testing.T, changes <-chan ports.SecurityState) ports.SecurityState {
55	t.Helper()
56	select {
57	case state := <-changes:
58		return state
59	case <-time.After(2 * time.Second):
60		t.Fatal("missing security transition")
61		return ports.SecurityState{}
62	}
63}
64
65func lifecycleAcquire(t *testing.T, c *wlturbo.Display) (*lockclient.ExtSessionLock, *int) {
66	t.Helper()
67	lock, err := bindLockManager(t, c).Lock()
68	if err != nil {
69		t.Fatal(err)
70	}
71	count := new(int)
72	lock.OnLocked(func() { *count++ })
73	roundtrip(t, c)
74	return lock, count
75}
76
77func lifecycleProof(s *Server, state ports.SecurityState, instance ports.OutputInstance) {
78	s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}})
79}
80
81func TestSessionLockLifecycleCaptureCompletionAndProofEpoch(t *testing.T) {
82	s, gate, changes, captured, dir := lifecycleLockServer(t)
83	c := protocolClient(t, s, dir)
84	s.display.Do(func() {
85		s.captureInflight[77] = struct{}{}
86		s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"})
87	})
88	_, locked := lifecycleAcquire(t, c)
89	state := lifecycleTransition(t, changes)
90	s.display.Do(func() {
91		s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
92		lifecycleProof(s, ports.SecurityState{Generation: state.Generation + 1}, 1)
93		lifecycleProof(s, state, 99) // unregistered output must not satisfy inventory
94		lifecycleProof(s, state, 1)
95		if s.sessionLock.locked {
96			t.Error("capture credit bypassed")
97		}
98	})
99	roundtrip(t, c)
100	if *locked != 0 {
101		t.Fatal("locked before capture completion")
102	}
103	// Unknown completion cannot retire an outstanding credit.
104	unknown := make(chan struct{})
105	s.display.Do(func() { s.captureReplies[88] = func(ports.CaptureDone) { close(unknown) } })
106	captured <- ports.CaptureDone{ID: 88}
107	select {
108	case <-unknown:
109	case <-time.After(2 * time.Second):
110		t.Fatal("unknown completion not consumed")
111	}
112	s.display.Do(func() {
113		if _, exists := s.captureInflight[77]; !exists || s.sessionLock.locked {
114			t.Error("unknown completion retired real credit")
115		}
116	})
117	processed := make(chan struct{})
118	s.display.Do(func() { s.captureReplies[77] = func(ports.CaptureDone) { close(processed) } })
119	captured <- ports.CaptureDone{ID: 77}
120	select {
121	case <-processed:
122	case <-time.After(2 * time.Second):
123		t.Fatal("capture completion not consumed")
124	}
125	roundtrip(t, c)
126	if *locked != 1 || !gate.Snapshot().Protected {
127		t.Fatalf("completion locked=%d gate=%+v", *locked, gate.Snapshot())
128	}
129	s.display.Do(func() { lifecycleProof(s, state, 1); s.maybeLocked() })
130	roundtrip(t, c)
131	if *locked != 1 {
132		t.Fatal("locked event repeated")
133	}
134}
135
136func TestSessionLockLifecycleUnlockProtocolErrors(t *testing.T) {
137	for _, scenario := range []string{"unlock before locked", "destroy after locked"} {
138		t.Run(scenario, func(t *testing.T) {
139			s, gate, changes, _, dir := lockManagerServer(t)
140			c := protocolClient(t, s, dir)
141			lock, locked := lifecycleAcquire(t, c)
142			state := lifecycleTransition(t, changes)
143			code := serverlock.ExtSessionLockV1ErrorInvalidUnlock
144			id := lock.ID()
145			if scenario == "destroy after locked" {
146				s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
147				roundtrip(t, c)
148				if *locked != 1 {
149					t.Fatal("zero-output backend barrier did not confirm")
150				}
151				code = serverlock.ExtSessionLockV1ErrorInvalidDestroy
152				if err := lock.Destroy(); err != nil {
153					t.Fatal(err)
154				}
155			} else {
156				if err := lock.UnlockAndDestroy(); err != nil {
157					t.Fatal(err)
158				}
159			}
160			expectProtocolError(t, c, id, uint32(code))
161			if !gate.Snapshot().Protected {
162				t.Fatal("invalid destructor unlocked")
163			}
164		})
165	}
166}
167
168func TestSessionLockLifecycleOrphanTakeoverAndValidUnlock(t *testing.T) {
169	s, gate, changes, _, dir := lockManagerServer(t)
170	c := protocolClient(t, s, dir)
171	_, locked := lifecycleAcquire(t, c)
172	firstState := lifecycleTransition(t, changes)
173	s.display.Do(func() {
174		s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"})
175		s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: firstState.Generation})
176		lifecycleProof(s, firstState, 1)
177	})
178	roundtrip(t, c)
179	if *locked != 1 {
180		t.Fatal("first lock not confirmed")
181	}
182	orphaned := make(chan struct{})
183	s.display.Do(func() { r := s.sessionLock.res; old := r.OnDestroy; r.OnDestroy = func() { old(); close(orphaned) } })
184	if err := c.Close(); err != nil {
185		t.Fatal(err)
186	}
187	select {
188	case <-orphaned:
189	case <-time.After(2 * time.Second):
190		t.Fatal("owner disconnect not processed")
191	}
192	s.display.Do(func() {
193		if s.sessionLock != nil || !gate.Snapshot().Protected {
194			t.Error("orphan released protection")
195		}
196	})
197	next := protocolClient(t, s, dir)
198	second, secondLocked := lifecycleAcquire(t, next)
199	state := lifecycleTransition(t, changes)
200	if state.Generation <= firstState.Generation || !state.Protected {
201		t.Fatalf("takeover state %+v", state)
202	}
203	s.display.Do(func() {
204		s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
205		lifecycleProof(s, firstState, 1)
206	})
207	roundtrip(t, next)
208	if *secondLocked != 0 {
209		t.Fatal("stale proof legalized takeover")
210	}
211	s.display.Do(func() { lifecycleProof(s, state, 1) })
212	roundtrip(t, next)
213	if *secondLocked != 1 {
214		t.Fatal("takeover not confirmed")
215	}
216	if err := second.UnlockAndDestroy(); err != nil {
217		t.Fatal(err)
218	}
219	roundtrip(t, next) // explicit server processing before client exit
220	released := lifecycleTransition(t, changes)
221	s.display.Do(func() {
222		if s.sessionLock != nil || gate.Snapshot().Protected || len(s.seat.heldKeys) != 0 || len(s.seat.grabKeys) != 0 {
223			t.Error("valid unlock failed to reset ownership/input")
224		}
225	})
226	if released.Protected || released.Generation <= state.Generation {
227		t.Fatalf("unlock transition %+v", released)
228	}
229}
230
231func lifecycleSurfaceOutput(t *testing.T, c *wlturbo.Display) (*core.Surface, *core.Output) {
232	t.Helper()
233	comp := core.NewCompositor(c.Context())
234	g, ok := c.Registry().FindGlobal("wl_compositor")
235	if !ok {
236		t.Fatal("compositor missing")
237	}
238	if err := c.Registry().Bind(g.Name, g.Interface, 6, comp); err != nil {
239		t.Fatal(err)
240	}
241	surf, err := comp.CreateSurface()
242	if err != nil {
243		t.Fatal(err)
244	}
245	out := core.NewOutput(c.Context())
246	g, ok = c.Registry().FindGlobal("wl_output")
247	if !ok {
248		t.Fatal("output missing")
249	}
250	if err := c.Registry().Bind(g.Name, g.Interface, 4, out); err != nil {
251		t.Fatal(err)
252	}
253	return surf, out
254}
255
256func TestSessionLockLifecycleRefusedPipelinedSurfaceInert(t *testing.T) {
257	s, gate, changes, _, dir := lockManagerServer(t)
258	c := protocolClient(t, s, dir)
259	manager := bindLockManager(t, c)
260	accepted, err := manager.Lock()
261	if err != nil {
262		t.Fatal(err)
263	}
264	refused, err := manager.Lock()
265	if err != nil {
266		t.Fatal(err)
267	}
268	finished, configured := 0, 0
269	refused.OnFinished(func() { finished++ })
270	surf, out := lifecycleSurfaceOutput(t, c)
271	role, err := refused.GetLockSurface(surf, out)
272	if err != nil {
273		t.Fatal(err)
274	}
275	role.OnConfigure(func(uint32, uint32, uint32) { configured++ })
276	requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
277	requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit)
278	roundtrip(t, c)
279	state := lifecycleTransition(t, changes)
280	s.display.Do(func() {
281		stateSurf := s.surfaceOf(findTestSurface(s, surf.ID()))
282		if stateSurf.kind != roleNone || stateSurf.lock != nil || len(s.lockSurfaces) != 0 || s.sessionLock.res.ID() != accepted.ID() || s.lockInputTarget(stateSurf.windowID()) {
283			t.Error("refused surface gained active role/input")
284		}
285	})
286	if finished != 1 || configured != 0 || gate.Snapshot() != state {
287		t.Fatalf("refused finished=%d configure=%d", finished, configured)
288	}
289	if err := role.Destroy(); err != nil {
290		t.Fatal(err)
291	}
292	if err := refused.Destroy(); err != nil {
293		t.Fatal(err)
294	}
295	roundtrip(t, c)
296}
297
298func TestSessionLockLifecycleIMEGrabCannotReceiveLockerKey(t *testing.T) {
299	s, _, changes, _, dir := lifecycleLockServer(t)
300	ime := newIMEApp(t, s, dir)
301	_, grab := ime.grab(t)
302	roundtrip(t, ime.c)
303	grab.take()
304	locker := protocolClient(t, s, dir)
305	lock, locked := lifecycleAcquire(t, locker)
306	state := lifecycleTransition(t, changes)
307	surf, out := lifecycleSurfaceOutput(t, locker)
308	role, err := lock.GetLockSurface(surf, out)
309	if err != nil {
310		t.Fatal(err)
311	}
312	var cfg lockConfigure
313	role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
314	roundtrip(t, locker)
315	if cfg.serial == 0 {
316		t.Fatal("configure missing")
317	}
318	if err := role.AckConfigure(cfg.serial); err != nil {
319		t.Fatal(err)
320	}
321	lockViewport(t, locker, surf.ID(), cfg.width, cfg.height)
322	requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, locker), int32(0), int32(0))
323	requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestCommit)
324	seat := bindProtocol(t, locker, "wl_seat")
325	keys := &eventsProxy{}
326	keys.SetID(locker.AllocateID())
327	registerWireProxy(locker, keys)
328	requestProtocol(t, locker, seat, wayland.SeatRequestGetKeyboard, keys.ID())
329	roundtrip(t, locker)
330	s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
331	roundtrip(t, locker)
332	if *locked != 1 {
333		t.Fatal("lock not confirmed")
334	}
335	s.display.Do(func() {
336		l := s.surfaceOf(findTestSurface(s, surf.ID())).lock
337		s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: l.id}})
338		s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: l.id, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}})
339	})
340	settle(t, locker, ime.c)
341	for _, event := range grab.take() {
342		if strings.HasPrefix(event, "key ") {
343			t.Fatalf("locker key leaked to IME: %s", event)
344		}
345	}
346	if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventKey)) {
347		t.Fatal("locker did not receive key")
348	}
349}
350
351func TestSessionLockLifecycleLatePrelockLeaseRevokedAfterUnlock(t *testing.T) {
352	requests := make(chan ports.LeaseMessage, 16)
353	s, gate, changes, _, dir := lifecycleLockServer(t, requests)
354	c := protocolClient(t, s, dir)
355	const card = "/dev/dri/card-lifecycle"
356	s.display.Do(func() {
357		s.applyLeaseEvent(ports.LeaseConnectors{Card: card, Device: leaseFD(t), Connectors: []ports.LeaseConnector{{Card: card, Name: "DP-1", ConnectorID: 42}}})
358	})
359	roundtrip(t, c)
360	dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
361	device := &leaseDeviceEvents{c: c}
362	device.SetID(dev)
363	registerWireProxy(c, device)
364	roundtrip(t, c)
365	for _, fd := range device.fds {
366		defer unix.Close(fd)
367	}
368	if len(device.connectors) != 1 {
369		t.Fatal("lease connector missing")
370	}
371	req := c.AllocateID()
372	registerProtocol(t, c, req)
373	requestProtocol(t, c, dev, drmlease.WpDrmLeaseDeviceV1RequestCreateLeaseRequest, req)
374	requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestRequestConnector, device.connectors[0])
375	result := &leaseResultEvents{}
376	result.SetID(c.AllocateID())
377	registerWireProxy(c, result)
378	requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestSubmit, result.ID())
379	roundtrip(t, c)
380	var pending ports.LeaseRequest
381	select {
382	case message := <-requests:
383		var ok bool
384		pending, ok = message.(ports.LeaseRequest)
385		if !ok {
386			t.Fatalf("request %T", message)
387		}
388	case <-time.After(2 * time.Second):
389		t.Fatal("lease request not sent")
390	}
391	lock, locked := lifecycleAcquire(t, c)
392	state := lifecycleTransition(t, changes)
393	s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
394	roundtrip(t, c)
395	if *locked != 1 || result.finished != 1 {
396		t.Fatalf("acquire locked=%d lease finished=%d", *locked, result.finished)
397	}
398	if err := lock.UnlockAndDestroy(); err != nil {
399		t.Fatal(err)
400	}
401	roundtrip(t, c)
402	lifecycleTransition(t, changes)
403	if gate.Snapshot().Protected {
404		t.Fatal("valid unlock failed")
405	}
406	fd := leaseFD(t)
407	s.display.Do(func() { s.applyLeaseEvent(ports.LeaseReply{ID: pending.ID, LeaseID: 123, FD: fd}) })
408	roundtrip(t, c)
409	if _, err := fd.Stat(); err == nil {
410		t.Fatal("late lease FD not closed")
411	}
412	select {
413	case message := <-requests:
414		revoke, ok := message.(ports.LeaseRevoke)
415		if !ok || revoke.Card != card || revoke.LeaseID != 123 {
416			t.Fatalf("late reply request %+v", message)
417		}
418	case <-time.After(2 * time.Second):
419		t.Fatal("late lease was not revoked")
420	}
421	s.display.Do(func() {
422		if len(s.pendingLeases) != 0 || len(s.pendingLeaseCards) != 0 || len(s.activeLeases) != 0 {
423			t.Error("late reply retained authority")
424		}
425	})
426	if len(result.fds) != 0 {
427		for _, fd := range result.fds {
428			unix.Close(fd)
429		}
430		t.Fatal("late lease FD delivered after unlock")
431	}
432}
433
434func TestSessionLockLifecycleRoleUnmapSendsSeatLeaves(t *testing.T) {
435	for _, kind := range []string{"role destroy", "output removal"} {
436		t.Run(kind, func(t *testing.T) {
437			s, gate, changes, _, dir := lifecycleLockServer(t)
438			c := protocolClient(t, s, dir)
439			lock, locked := lifecycleAcquire(t, c)
440			state := lifecycleTransition(t, changes)
441			surf, out := lifecycleSurfaceOutput(t, c)
442			role, err := lock.GetLockSurface(surf, out)
443			if err != nil {
444				t.Fatal(err)
445			}
446			var cfg lockConfigure
447			role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
448			roundtrip(t, c)
449			if err := role.AckConfigure(cfg.serial); err != nil {
450				t.Fatal(err)
451			}
452			lockViewport(t, c, surf.ID(), cfg.width, cfg.height)
453			requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
454			requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit)
455			seat := bindProtocol(t, c, "wl_seat")
456			keys := &eventsProxy{}
457			keys.SetID(c.AllocateID())
458			registerWireProxy(c, keys)
459			requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())
460			pointer := newIMELog(c, c.AllocateID(), "enter:uoff", "leave:uo", "motion:uff", "button:uuuu", "axis:uuf")
461			requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer.ID())
462			roundtrip(t, c)
463			s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
464			roundtrip(t, c)
465			if *locked != 1 {
466				t.Fatal("lock not confirmed")
467			}
468			s.display.Do(func() {
469				l := s.surfaceOf(findTestSurface(s, surf.ID())).lock
470				s.changeFocus(l.id)
471				s.changePointerFocus(l.id, 1, 2)
472			})
473			roundtrip(t, c)
474			if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventEnter)) {
475				t.Fatal("keyboard enter missing")
476			}
477			keys.opcodes = nil
478			pointer.take()
479			if kind == "role destroy" {
480				if err := role.Destroy(); err != nil {
481					t.Fatal(err)
482				}
483			} else {
484				s.display.Do(func() { s.setOutputs(ports.SetOutputs{}) })
485			}
486			roundtrip(t, c)
487			if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventLeave)) {
488				t.Fatal("live lock keyboard did not receive leave")
489			}
490			leave := false
491			for _, event := range pointer.take() {
492				if strings.HasPrefix(event, "leave ") {
493					leave = true
494				}
495			}
496			if !leave {
497				t.Fatal("live lock pointer did not receive leave")
498			}
499			s.display.Do(func() {
500				if s.seat.focused != 0 || s.seat.pointerFocus != 0 || !gate.Snapshot().Protected || !findTestSurface(s, surf.ID()).Alive() {
501					t.Error("unmap lost protection/live wl_surface or retained seat focus")
502				}
503			})
504		})
505	}
506}
507
508func TestSessionLockLifecycleBackendOutboxPreservesBlockedTransitions(t *testing.T) {
509	s, gate, changes, _, dir := lockManagerServer(t)
510	// Fill the backend channel before any transition; display remains responsive.
511	sentinel := ports.SecurityState{Generation: 999}
512	changes <- sentinel
513	c := protocolClient(t, s, dir)
514	lock, locked := lifecycleAcquire(t, c)
515	state := gate.Snapshot()
516	s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
517	roundtrip(t, c)
518	if *locked != 1 {
519		t.Fatal("test protection proof not consumed")
520	}
521	if err := lock.UnlockAndDestroy(); err != nil {
522		t.Fatal(err)
523	}
524	roundtrip(t, c)
525	released := gate.Snapshot()
526	s.display.Do(func() {
527		if len(s.securityPending) < 2 || s.securityPending[0] != state || s.securityPending[1] != released {
528			t.Errorf("blocked outbox lost ordered transitions: %+v", s.securityPending)
529		}
530	})
531	if got := lifecycleTransition(t, changes); got != sentinel {
532		t.Fatalf("sentinel %+v", got)
533	}
534	if got := lifecycleTransition(t, changes); got != state {
535		t.Fatalf("acquire %+v", got)
536	}
537	if got := lifecycleTransition(t, changes); got != released {
538		t.Fatalf("release %+v", got)
539	}
540	if released.Protected || released.Generation <= state.Generation {
541		t.Fatal("unlock generation did not advance")
542	}
543}

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 25

Tags 10

↑↓ moveenter openesc close