v0.3.0 99dc266

remote_preview_cache_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	renderer "github.com/bnema/vev-vt"
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/ports"
15	"github.com/bnema/vev/internal/usecase/picker"
16)
17
18type remotePreviewTestClock struct {
19	mu  sync.Mutex
20	now time.Time
21}
22
23func (c *remotePreviewTestClock) Now() time.Time {
24	c.mu.Lock()
25	defer c.mu.Unlock()
26	return c.now
27}
28
29func (c *remotePreviewTestClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
30
31func (c *remotePreviewTestClock) Advance(delta time.Duration) {
32	c.mu.Lock()
33	c.now = c.now.Add(delta)
34	c.mu.Unlock()
35}
36
37type remotePreviewTestClient struct {
38	mu             sync.Mutex
39	calls          int
40	result         ports.RemotePreview
41	err            error
42	started        chan struct{}
43	finished       chan struct{}
44	release        chan struct{}
45	lastWidth      uint16
46	lastHeight     uint16
47	lastTarget     domain.RemoteSessionTarget
48	ignoreCancel   bool
49	startedSignal  bool
50	finishedSignal bool
51}
52
53func (c *remotePreviewTestClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
54	c.mu.Lock()
55	c.calls++
56	c.lastTarget = target
57	c.lastWidth, c.lastHeight = width, height
58	result, err := c.result, c.err
59	started, release, finished := c.started, c.release, c.finished
60	if started != nil && !c.startedSignal {
61		c.startedSignal = true
62		close(started)
63	}
64	c.mu.Unlock()
65	if release != nil {
66		if c.ignoreCancel {
67			<-release
68		} else {
69			select {
70			case <-release:
71			case <-ctx.Done():
72				return ports.RemotePreview{}, ctx.Err()
73			}
74		}
75	}
76	if finished != nil {
77		c.mu.Lock()
78		if !c.finishedSignal {
79			c.finishedSignal = true
80			close(finished)
81		}
82		c.mu.Unlock()
83	}
84	return cloneRemotePreview(result), err
85}
86
87func (c *remotePreviewTestClient) Calls() int {
88	c.mu.Lock()
89	defer c.mu.Unlock()
90	return c.calls
91}
92
93func (c *remotePreviewTestClient) LastSize() (uint16, uint16) {
94	c.mu.Lock()
95	defer c.mu.Unlock()
96	return c.lastWidth, c.lastHeight
97}
98
99func (c *remotePreviewTestClient) LastTarget() domain.RemoteSessionTarget {
100	c.mu.Lock()
101	defer c.mu.Unlock()
102	return c.lastTarget
103}
104
105func remotePreviewCacheTarget() domain.RemoteSessionTarget {
106	var lifecycle domain.SessionLifecycleID
107	lifecycle[0] = 0x42
108	return domain.RemoteSessionTarget{
109		Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
110		SessionName: "work", LiveTabID: "tab-1",
111	}
112}
113
114func remotePreviewCacheResult(target domain.RemoteSessionTarget, revision uint64) ports.RemotePreview {
115	return remotePreviewCacheResultRune(target, revision, 'x')
116}
117
118func remotePreviewCacheResultRune(target domain.RemoteSessionTarget, revision uint64, value rune) ports.RemotePreview {
119	return ports.RemotePreview{
120		Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
121		LifecycleID: target.LifecycleID, TabID: target.LiveTabID,
122		Revision: revision, Width: 1, Height: 1,
123		Cells: []renderer.Cell{{Rune: value, Style: renderer.DefaultStyle()}},
124	}
125}
126
127func TestFetchRemotePreviewSingleFlightAndCopiesCache(t *testing.T) {
128	clock := &remotePreviewTestClock{now: time.Unix(100, 0)}
129	target := remotePreviewCacheTarget()
130	client := &remotePreviewTestClient{
131		result:  remotePreviewCacheResult(target, 1),
132		started: make(chan struct{}),
133		release: make(chan struct{}),
134	}
135	d := newTestDaemon(t, nil, clock)
136	d.remotePreviewClient = client
137
138	firstDone := make(chan struct{})
139	var first ports.RemotePreview
140	var firstErr error
141	go func() {
142		first, firstErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
143		close(firstDone)
144	}()
145	<-client.started
146
147	secondDone := make(chan struct{})
148	var second ports.RemotePreview
149	var secondErr error
150	go func() {
151		second, secondErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
152		close(secondDone)
153	}()
154	close(client.release)
155	<-firstDone
156	<-secondDone
157	require.NoError(t, firstErr)
158	require.NoError(t, secondErr)
159	require.Equal(t, first, second)
160	require.Equal(t, 1, client.Calls(), "same key must use one remote request")
161
162	first.Cells[0].Rune = 'm'
163	cached, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
164	require.NoError(t, err)
165	require.Equal(t, rune('x'), cached.Cells[0].Rune, "callers must not mutate the memory-only cache")
166	require.Equal(t, 1, client.Calls())
167}
168
169func TestFetchRemotePreviewAdapterTimeoutAppliesCooldown(t *testing.T) {
170	clock := &remotePreviewTestClock{now: time.Unix(250, 0)}
171	target := remotePreviewCacheTarget()
172	client := &remotePreviewTestClient{err: ports.ErrRemotePreviewTimeout}
173	d := newTestDaemon(t, nil, clock)
174	d.remotePreviewClient = client
175
176	_, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
177	require.ErrorIs(t, firstErr, ports.ErrRemotePreviewTimeout)
178	_, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
179	require.ErrorIs(t, secondErr, errRemotePreviewCooldown)
180	require.Equal(t, 1, client.Calls())
181}
182
183func TestFetchRemotePreviewRejectsMalformedResponseAndAppliesCooldown(t *testing.T) {
184	clock := &remotePreviewTestClock{now: time.Unix(200, 0)}
185	target := remotePreviewCacheTarget()
186	client := &remotePreviewTestClient{err: errors.New("remote unavailable")}
187	d := newTestDaemon(t, nil, clock)
188	d.remotePreviewClient = client
189
190	_, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
191	require.Error(t, firstErr)
192	_, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
193	require.Error(t, secondErr)
194	require.Equal(t, 1, client.Calls(), "a failed target must be cooled down")
195
196	clock.Advance(remotePreviewCooldown)
197	_, thirdErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
198	require.Error(t, thirdErr)
199	require.Equal(t, 2, client.Calls(), "cooldown expiry must permit a retry")
200}
201
202func TestFetchRemotePreviewServesStaleWhileRefreshing(t *testing.T) {
203	clock := &remotePreviewTestClock{now: time.Unix(300, 0)}
204	target := remotePreviewCacheTarget()
205	client := &remotePreviewTestClient{result: remotePreviewCacheResult(target, 1)}
206	d := newTestDaemon(t, nil, clock)
207	d.remotePreviewClient = client
208
209	first, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
210	require.NoError(t, err)
211	require.Equal(t, uint64(1), first.Revision)
212
213	clock.Advance(remotePreviewCacheTTL + time.Second)
214	client.mu.Lock()
215	client.result = remotePreviewCacheResult(target, 2)
216	client.started = make(chan struct{})
217	client.release = make(chan struct{})
218	client.startedSignal = false
219	client.finishedSignal = false
220	client.mu.Unlock()
221
222	stale, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
223	require.NoError(t, err)
224	require.Equal(t, uint64(1), stale.Revision, "expired content remains available during revalidation")
225	<-client.started
226	key := remotePreviewKeyFor(target, 1, 1)
227	d.remotePreview.mu.Lock()
228	flight := d.remotePreview.flights[key]
229	d.remotePreview.mu.Unlock()
230	require.NotNil(t, flight)
231	close(client.release)
232	<-flight.done
233
234	fresh, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
235	require.NoError(t, err)
236	require.Equal(t, uint64(2), fresh.Revision)
237	require.Equal(t, 2, client.Calls())
238}
239
240type immediateRemotePreviewTimer struct{ ch <-chan time.Time }
241
242func (t immediateRemotePreviewTimer) C() <-chan time.Time      { return t.ch }
243func (t immediateRemotePreviewTimer) Reset(time.Duration) bool { return false }
244func (t immediateRemotePreviewTimer) Stop() bool               { return true }
245
246type immediateRemotePreviewClock struct{}
247
248func (immediateRemotePreviewClock) Now() time.Time { return time.Unix(500, 0) }
249func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer {
250	ch := make(chan time.Time, 1)
251	ch <- time.Unix(500, 0)
252	return immediateRemotePreviewTimer{ch: ch}
253}
254
255func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *testing.T) {
256	d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
257	firstTarget := remotePreviewCacheTarget()
258	firstStarted := make(chan struct{})
259	firstRelease := make(chan struct{})
260	client := &remotePreviewTestClient{
261		result:       remotePreviewCacheResult(firstTarget, 1),
262		started:      firstStarted,
263		release:      firstRelease,
264		ignoreCancel: true,
265	}
266	d.remotePreviewClient = client
267	_, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
268
269	firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
270	firstView := picker.SessionView{
271		ID: firstKey.ID(), Name: firstTarget.SessionName,
272		Tabs:      []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}},
273		RemoteKey: &firstKey, RemoteTarget: &firstTarget,
274		RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
275	}
276	firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
277	firstSelected, ok := firstModel.Selected()
278	require.True(t, ok)
279	ac.overlays.pickerMu.Lock()
280	ac.overlays.picker = firstModel
281	ac.overlays.pickerIntent = pickerNavigate
282	ac.overlays.pickerPreviewGeneration = 1
283	ac.overlays.pickerMu.Unlock()
284	d.startRemotePickerPreview(ac, firstSelected, 1)
285	<-firstStarted
286
287	d.closePicker(ac)
288
289	secondTarget := firstTarget
290	secondTarget.LifecycleID[0]++
291	secondTarget.LiveTabID = "tab-2"
292	client.mu.Lock()
293	client.result = remotePreviewCacheResultRune(secondTarget, 2, 'y')
294	client.started = make(chan struct{})
295	client.release = make(chan struct{})
296	client.startedSignal = false
297	client.finishedSignal = false
298	client.mu.Unlock()
299	secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
300	secondView := picker.SessionView{
301		ID: secondKey.ID(), Name: secondTarget.SessionName,
302		Tabs:      []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}},
303		RemoteKey: &secondKey, RemoteTarget: &secondTarget,
304		RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
305	}
306	secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
307	secondSelected, ok := secondModel.Selected()
308	require.True(t, ok)
309	ac.overlays.pickerMu.Lock()
310	ac.overlays.picker = secondModel
311	ac.overlays.pickerIntent = pickerNavigate
312	ac.overlays.pickerPreviewGeneration++
313	secondGeneration := ac.overlays.pickerPreviewGeneration
314	ac.overlays.pickerMu.Unlock()
315	d.startRemotePickerPreview(ac, secondSelected, secondGeneration)
316	<-client.started
317
318	close(firstRelease)
319	close(client.release)
320	width, height := remotePickerPreviewSize(ac.sizeSnapshot())
321	for _, key := range []remotePreviewCacheKey{
322		remotePreviewKeyFor(firstTarget, width, height),
323		remotePreviewKeyFor(secondTarget, width, height),
324	} {
325		d.remotePreview.mu.Lock()
326		flight := d.remotePreview.flights[key]
327		d.remotePreview.mu.Unlock()
328		if flight != nil {
329			<-flight.done
330		}
331	}
332	var preview picker.Preview
333	require.Eventually(t, func() bool {
334		ac.overlays.pickerMu.Lock()
335		preview = ac.overlays.pickerRemotePreview
336		currentGeneration := ac.overlays.pickerPreviewGeneration
337		ac.overlays.pickerMu.Unlock()
338		return currentGeneration == secondGeneration && len(preview.Rows) != 0 && len(preview.Rows[0]) != 0 && preview.Rows[0][0].Rune == 'y'
339	}, time.Second, time.Millisecond)
340	require.Equal(t, rune('y'), preview.Rows[0][0].Rune)
341	require.Equal(t, 2, client.Calls())
342}
343
344func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) {
345	d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
346	firstTarget := remotePreviewCacheTarget()
347	secondTarget := firstTarget
348	secondTarget.LifecycleID[0]++
349	secondTarget.LiveTabID = "tab-2"
350	client := &remotePreviewTestClient{result: remotePreviewCacheResult(firstTarget, 1), started: make(chan struct{})}
351	d.remotePreviewClient = client
352	_, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
353	firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
354	secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
355	views := []picker.SessionView{
356		{ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
357		{ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
358	}
359	model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
360	ac.overlays.pickerMu.Lock()
361	ac.overlays.picker = model
362	ac.overlays.pickerIntent = pickerNavigate
363	ac.overlays.pickerPreviewGeneration = 1
364	ac.overlays.pickerMu.Unlock()
365	d.registerPreviewForSelection(ac)
366	<-client.started
367	require.Equal(t, firstTarget, client.LastTarget())
368
369	client.mu.Lock()
370	client.result = remotePreviewCacheResult(secondTarget, 2)
371	client.started = make(chan struct{})
372	client.startedSignal = false
373	client.mu.Unlock()
374	ac.overlays.pickerMu.Lock()
375	model.Down()
376	ac.overlays.pickerMu.Unlock()
377	d.registerPreviewForSelection(ac)
378	<-client.started
379	require.Equal(t, secondTarget, client.LastTarget())
380	require.Equal(t, 2, client.Calls())
381}
382
383func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) {
384	d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
385	firstTarget := remotePreviewCacheTarget()
386	secondTarget := firstTarget
387	secondTarget.LifecycleID[0]++
388	secondTarget.LiveTabID = "tab-2"
389	client := &remotePreviewTestClient{
390		err:     errors.New("remote unavailable"),
391		started: make(chan struct{}),
392		release: make(chan struct{}),
393	}
394	d.remotePreviewClient = client
395	_, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
396	firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
397	secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
398	model := picker.New([]picker.SessionView{
399		{ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
400		{ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
401	}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
402	selected, ok := model.Selected()
403	require.True(t, ok)
404	ac.overlays.pickerMu.Lock()
405	ac.overlays.picker = model
406	ac.overlays.pickerIntent = pickerNavigate
407	ac.overlays.pickerPreviewGeneration = 1
408	ac.overlays.pickerMu.Unlock()
409	d.startRemotePickerPreview(ac, selected, 1)
410	<-client.started
411
412	ac.overlays.pickerMu.Lock()
413	ac.overlays.pickerRemotePreview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: 'k', Style: renderer.DefaultStyle()}}}}
414	model.Down()
415	ac.overlays.pickerMu.Unlock()
416	close(client.release)
417
418	require.Eventually(t, func() bool {
419		ac.overlays.pickerMu.Lock()
420		defer ac.overlays.pickerMu.Unlock()
421		return len(ac.overlays.pickerRemotePreview.Rows) == 1 && ac.overlays.pickerRemotePreview.Rows[0][0].Rune == 'k'
422	}, time.Second, time.Millisecond)
423}
424
425func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
426	d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
427	target := remotePreviewCacheTarget()
428	firstStarted := make(chan struct{})
429	client := &remotePreviewTestClient{
430		result:  remotePreviewCacheResult(target, 1),
431		started: firstStarted,
432		release: make(chan struct{}),
433	}
434	d.remotePreviewClient = client
435	sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
436	key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
437	view := picker.SessionView{
438		ID: key.ID(), Name: target.SessionName,
439		Tabs:      []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
440		RemoteKey: &key, RemoteTarget: &target,
441		RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
442	}
443	model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
444	selected, ok := model.Selected()
445	require.True(t, ok)
446	ac.overlays.pickerMu.Lock()
447	ac.overlays.picker = model
448	ac.overlays.pickerIntent = pickerNavigate
449	ac.overlays.pickerPreviewGeneration = 1
450	ac.overlays.pickerMu.Unlock()
451	d.startRemotePickerPreview(ac, selected, 1)
452	<-firstStarted
453
454	secondStarted := make(chan struct{})
455	client.mu.Lock()
456	client.started = secondStarted
457	client.release = nil
458	client.startedSignal = false
459	client.mu.Unlock()
460	token := sess.attachmentToken(ac, ac.transport())
461	effect, admitted := ac.beginAttachmentEffect(token)
462	require.True(t, admitted)
463	token.effect = effect
464	defer effect.End()
465	require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
466	<-secondStarted
467	wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})
468	gotWidth, gotHeight := client.LastSize()
469	require.Equal(t, wantWidth, gotWidth)
470	require.Equal(t, wantHeight, gotHeight)
471
472	require.Eventually(t, func() bool {
473		ac.overlays.pickerMu.Lock()
474		defer ac.overlays.pickerMu.Unlock()
475		return ac.overlays.pickerRemotePreview.Width == 1 && ac.overlays.pickerRemotePreview.Height == 1
476	}, time.Second, time.Millisecond)
477	require.Equal(t, 2, client.Calls())
478}
479
480func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) {
481	clock := &remotePreviewTestClock{now: time.Unix(400, 0)}
482	client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)}
483	d := newTestDaemon(t, nil, clock)
484	d.remotePreviewClient = client
485
486	_, err := d.fetchRemotePreview(context.Background(), remotePreviewCacheTarget(), ports.RemotePreviewMaxWidth+1, 1)
487	require.ErrorIs(t, err, ports.ErrInvalidRemotePreviewRequest)
488	require.Zero(t, client.Calls())
489}

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 12

Tags 11

↑↓ moveenter openesc close