router_test.go RawHistoryFolder

1package keys
2
3import (
4	"runtime"
5	"strings"
6	"sync/atomic"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/vev/internal/ports"
13)
14
15type fakeClock struct{ timers []*fakeTimer }
16
17func (c *fakeClock) Now() time.Time { return time.Time{} }
18func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
19	t := &fakeTimer{ch: make(chan time.Time, 1), d: d}
20	c.timers = append(c.timers, t)
21	return t
22}
23
24type fakeTimer struct {
25	ch      chan time.Time
26	d       time.Duration
27	stopped bool
28}
29
30func (t *fakeTimer) C() <-chan time.Time        { return t.ch }
31func (t *fakeTimer) Reset(d time.Duration) bool { t.d = d; return t.stopped }
32func (t *fakeTimer) Stop() bool                 { t.stopped = true; return true }
33func (t *fakeTimer) fire()                      { t.ch <- time.Time{} }
34
35type captureHandler struct {
36	forwards  [][]byte
37	actions   []Action
38	actionRaw [][]byte
39	notify    chan struct{}
40}
41
42func (h *captureHandler) Forward(data []byte) {
43	h.forwards = append(h.forwards, append([]byte(nil), data...))
44	if h.notify != nil {
45		h.notify <- struct{}{}
46	}
47}
48func (h *captureHandler) Action(a Action, raw []byte) {
49	h.actions = append(h.actions, a)
50	h.actionRaw = append(h.actionRaw, append([]byte(nil), raw...))
51}
52
53func TestRouterRawActionPreservesExactConsumedBytes(t *testing.T) {
54	custom := DefaultBindings().clone()
55	custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'ø'}})
56	var bindings atomic.Pointer[Bindings]
57	bindings.Store(custom)
58
59	tests := []struct {
60		name   string
61		reads  [][]byte
62		action Action
63		raw    []byte
64	}{
65		{name: "single frame", reads: [][]byte{{ESC, 'j'}}, action: ActionFocusPaneDown, raw: []byte{ESC, 'j'}},
66		{name: "delayed escape", reads: [][]byte{{ESC}, {'1'}}, action: ActionSwitchTab1, raw: []byte{ESC, '1'}},
67		{name: "split UTF-8 custom binding", reads: [][]byte{{ESC, 0xc3}, {0xb8}}, action: ActionEqualizePanes, raw: []byte{ESC, 0xc3, 0xb8}},
68		{name: "split Alt arrow", reads: [][]byte{{ESC, '[', '1'}, {';', '9', 'D'}}, action: ActionFocusPaneLeft, raw: []byte{ESC, '[', '1', ';', '9', 'D'}},
69	}
70
71	for _, tt := range tests {
72		t.Run(tt.name, func(t *testing.T) {
73			clk := &fakeClock{}
74			h := &captureHandler{}
75			router := NewRouter(clk, h, &bindings)
76			for _, read := range tt.reads {
77				router.Route(read)
78			}
79			require.Equal(t, []Action{tt.action}, h.actions)
80			require.Equal(t, [][]byte{tt.raw}, h.actionRaw)
81			require.Empty(t, h.forwards)
82		})
83	}
84}
85
86func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
87	cases := []struct {
88		name string
89		in   []byte
90		want Action
91	}{
92		{name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette},
93		{name: "floating pane", in: []byte{ESC, 'f'}, want: ActionToggleFloatingPane},
94		{name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention},
95		{name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft},
96		{name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown},
97		{name: "focus up", in: []byte{ESC, 'k'}, want: ActionFocusPaneUp},
98		{name: "focus right", in: []byte{ESC, 'l'}, want: ActionFocusPaneRight},
99		{name: "arrow left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft},
100		{name: "arrow right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight},
101		{name: "arrow up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp},
102		{name: "arrow down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown},
103		{name: "digit", in: []byte{ESC, '1'}, want: ActionSwitchTab1},
104		{name: "digit alias", in: append([]byte{ESC}, []byte("é")...), want: ActionSwitchTab2},
105	}
106
107	var bindings atomic.Pointer[Bindings]
108	bindings.Store(DefaultBindings())
109	for _, tc := range cases {
110		t.Run(tc.name, func(t *testing.T) {
111			clk := &fakeClock{}
112			h := &captureHandler{}
113			NewRouter(clk, h, &bindings).Route(tc.in)
114			require.Equal(t, []Action{tc.want}, h.actions)
115			require.Empty(t, h.forwards)
116		})
117	}
118}
119
120func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
121	clk := &fakeClock{}
122	h := &captureHandler{}
123	NewRouter(clk, h, nil).Route([]byte{ESC, ' '})
124	require.Equal(t, []Action{ActionOpenPalette}, h.actions)
125	require.Empty(t, h.forwards)
126	require.Empty(t, clk.timers)
127}
128
129func TestRouterInterceptsAltDigits(t *testing.T) {
130	clk := &fakeClock{}
131	h := &captureHandler{}
132	r := NewRouter(clk, h, nil)
133	for b := byte('1'); b <= '9'; b++ {
134		r.Route([]byte{ESC, b})
135	}
136	require.Equal(t, switchTabActions(), h.actions)
137	require.Empty(t, h.forwards)
138}
139
140func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
141	cases := []struct {
142		name string
143		keys []string
144	}{
145		{name: "QWERTY", keys: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9"}},
146		{name: "French AZERTY", keys: []string{"&", "é", "\"", "'", "(", "-", "è", "_", "ç"}},
147		{name: "Belgian AZERTY", keys: []string{"&", "é", "\"", "'", "(", "§", "è", "!", "ç"}},
148	}
149
150	for _, tc := range cases {
151		t.Run(tc.name, func(t *testing.T) {
152			clk := &fakeClock{}
153			h := &captureHandler{}
154			r := NewRouter(clk, h, nil)
155			for _, key := range tc.keys {
156				r.Route(append([]byte{ESC}, []byte(key)...))
157			}
158			require.Equal(t, switchTabActions(), h.actions)
159			require.Empty(t, h.forwards)
160		})
161	}
162}
163
164func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
165	clk := &fakeClock{}
166	h := &captureHandler{}
167	r := NewRouter(clk, h, nil)
168
169	r.Route([]byte{ESC, 0xc3})
170	require.Empty(t, h.actions)
171	require.Empty(t, h.forwards)
172	require.Len(t, clk.timers, 1)
173
174	r.Route([]byte{0xa9})
175	require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
176	require.Empty(t, h.forwards)
177	assertRouterPendingCleared(t, r)
178
179	r.Route([]byte{ESC, ' '})
180	require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
181	require.Empty(t, h.forwards)
182}
183
184func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
185	clk := &fakeClock{}
186	h := &captureHandler{}
187	r := NewRouter(clk, h, nil)
188
189	r.Route([]byte{ESC})
190	r.Route([]byte{0xc3})
191	require.Empty(t, h.actions)
192	require.Empty(t, h.forwards)
193	require.Len(t, clk.timers, 2)
194
195	r.Route([]byte{0xa9})
196	require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
197	require.Empty(t, h.forwards)
198	assertRouterPendingCleared(t, r)
199
200	r.Route([]byte{ESC, ' '})
201	require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
202	require.Empty(t, h.forwards)
203}
204
205func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
206	clk := &fakeClock{}
207	h := &captureHandler{}
208	r := NewRouter(clk, h, nil)
209
210	r.Route([]byte{ESC, 0xc3})
211	r.Route([]byte{0xb1})
212
213	require.Empty(t, h.actions)
214	require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
215	assertRouterPendingCleared(t, r)
216
217	r.Route([]byte("Z"))
218	require.Empty(t, h.actions)
219	require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}, []byte("Z")}, h.forwards)
220}
221
222func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
223	clk := &fakeClock{}
224	h := &captureHandler{}
225	r := NewRouter(clk, h, nil)
226
227	r.Route([]byte{ESC, 0xc3})
228	r.Route([]byte("X"))
229
230	require.Empty(t, h.actions)
231	require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
232	assertRouterPendingCleared(t, r)
233
234	r.Route([]byte{ESC, ' '})
235	require.Equal(t, []Action{ActionOpenPalette}, h.actions)
236	require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
237}
238
239func assertRouterPendingCleared(t *testing.T, r *Router) {
240	t.Helper()
241	require.False(t, r.pending)
242	require.Nil(t, r.pendingAlt)
243	require.Nil(t, r.timer)
244	require.Nil(t, r.pendingDone)
245}
246
247func TestTopRowDigitIndexAcceptsLayoutAliases(t *testing.T) {
248	for want, aliases := range [][]rune{
249		{'1', '&'},
250		{'2', 'é'},
251		{'3', '"'},
252		{'4', '\''},
253		{'5', '('},
254		{'6', '-', '§'},
255		{'7', 'è'},
256		{'8', '_', '!'},
257		{'9', 'ç'},
258	} {
259		for _, key := range aliases {
260			got, ok := topRowDigitIndex(key)
261			require.True(t, ok, "key %q", key)
262			require.Equal(t, want, got, "key %q", key)
263		}
264	}
265
266	_, ok := topRowDigitIndex('0')
267	require.False(t, ok)
268}
269
270func switchTabActions() []Action {
271	return []Action{
272		ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
273		ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
274		ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
275	}
276}
277
278func TestSwitchTabActionsRemainContiguous(t *testing.T) {
279	for i, action := range switchTabActions() {
280		require.Equal(t, ActionSwitchTab1+Action(i), action)
281	}
282	require.False(t, ActionToggleFloatingPane >= ActionSwitchTab1 && ActionToggleFloatingPane <= ActionSwitchTab9)
283}
284
285func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
286	clk := &fakeClock{}
287	h := &captureHandler{}
288	NewRouter(clk, h, nil).Route([]byte{ESC, 'a'})
289	require.Equal(t, []Action{ActionJumpAttention}, h.actions)
290	require.Empty(t, h.forwards)
291	require.Empty(t, clk.timers)
292}
293
294func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) {
295	cases := []struct {
296		name string
297		key  byte
298		want Action
299	}{
300		{name: "left", key: 'h', want: ActionFocusPaneLeft},
301		{name: "down", key: 'j', want: ActionFocusPaneDown},
302		{name: "up", key: 'k', want: ActionFocusPaneUp},
303		{name: "right", key: 'l', want: ActionFocusPaneRight},
304	}
305	for _, tc := range cases {
306		t.Run(tc.name, func(t *testing.T) {
307			clk := &fakeClock{}
308			h := &captureHandler{}
309			NewRouter(clk, h, nil).Route([]byte{ESC, tc.key})
310			require.Equal(t, []Action{tc.want}, h.actions)
311			require.Empty(t, h.forwards)
312			require.Empty(t, clk.timers)
313		})
314	}
315}
316
317func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
318	for _, b := range []byte("cnpdxurt") {
319		t.Run(string(b), func(t *testing.T) {
320			clk := &fakeClock{}
321			h := &captureHandler{}
322			NewRouter(clk, h, nil).Route([]byte{ESC, b})
323			require.Empty(t, h.actions)
324			require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
325		})
326	}
327}
328
329func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
330	clk := &fakeClock{}
331	h := &captureHandler{}
332	NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'})
333	require.Equal(t, []Action{ActionOpenPalette}, h.actions)
334	require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
335}
336
337func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
338	cases := [][]byte{{ESC, '[', 'A'}, {ESC, 'O', 'P'}}
339	for _, in := range cases {
340		clk := &fakeClock{}
341		h := &captureHandler{}
342		NewRouter(clk, h, nil).Route(in)
343		require.Empty(t, h.actions)
344		require.Equal(t, [][]byte{in}, h.forwards)
345		require.Empty(t, clk.timers)
346	}
347}
348
349func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) {
350	cases := []struct {
351		name string
352		in   []byte
353		want Action
354	}{
355		{name: "alt up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp},
356		{name: "alt down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown},
357		{name: "alt right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight},
358		{name: "alt left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft},
359		{name: "meta up", in: []byte{ESC, '[', '1', ';', '9', 'A'}, want: ActionFocusPaneUp},
360		{name: "meta down", in: []byte{ESC, '[', '1', ';', '9', 'B'}, want: ActionFocusPaneDown},
361		{name: "meta right", in: []byte{ESC, '[', '1', ';', '9', 'C'}, want: ActionFocusPaneRight},
362		{name: "meta left", in: []byte{ESC, '[', '1', ';', '9', 'D'}, want: ActionFocusPaneLeft},
363	}
364
365	for _, tc := range cases {
366		t.Run(tc.name, func(t *testing.T) {
367			clk := &fakeClock{}
368			h := &captureHandler{}
369			NewRouter(clk, h, nil).Route(tc.in)
370			require.Equal(t, []Action{tc.want}, h.actions)
371			require.Empty(t, h.forwards)
372			require.Empty(t, clk.timers)
373		})
374	}
375}
376
377func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
378	cases := []struct {
379		name   string
380		reads  [][]byte
381		want   Action
382		timers int
383	}{
384		{name: "esc then rest", reads: [][]byte{{ESC}, []byte("[1;3D")}, want: ActionFocusPaneLeft, timers: 1},
385		{name: "csi prefix then rest", reads: [][]byte{{ESC, '['}, []byte("1;3C")}, want: ActionFocusPaneRight, timers: 1},
386		{name: "modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '3'}, []byte("A")}, want: ActionFocusPaneUp, timers: 1},
387		{name: "meta modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '9'}, []byte("B")}, want: ActionFocusPaneDown, timers: 1},
388	}
389
390	for _, tc := range cases {
391		t.Run(tc.name, func(t *testing.T) {
392			clk := &fakeClock{}
393			h := &captureHandler{}
394			r := NewRouter(clk, h, nil)
395			for _, read := range tc.reads {
396				r.Route(read)
397			}
398			require.Equal(t, []Action{tc.want}, h.actions)
399			require.Empty(t, h.forwards)
400			require.Len(t, clk.timers, tc.timers)
401			assertRouterPendingCleared(t, r)
402		})
403	}
404}
405
406func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
407	cases := []struct {
408		name         string
409		reads        [][]byte
410		wantActions  []Action
411		wantForwards [][]byte
412	}{
413		{
414			name:        "two alt arrows in one read",
415			reads:       [][]byte{{ESC, '[', '1', ';', '3', 'A', ESC, '[', '1', ';', '3', 'A'}},
416			wantActions: []Action{ActionFocusPaneUp, ActionFocusPaneUp},
417		},
418		{
419			name:         "alt arrow followed by normal byte",
420			reads:        [][]byte{{ESC, '[', '1', ';', '3', 'A', 'x'}},
421			wantActions:  []Action{ActionFocusPaneUp},
422			wantForwards: [][]byte{[]byte("x")},
423		},
424		{
425			name:         "bare arrow passthrough",
426			reads:        [][]byte{{ESC, '[', 'A'}},
427			wantForwards: [][]byte{{ESC, '[', 'A'}},
428		},
429		{
430			name:        "retained escape alt arrow across reads",
431			reads:       [][]byte{{ESC}, []byte("[1;3A")},
432			wantActions: []Action{ActionFocusPaneUp},
433		},
434	}
435
436	for _, tc := range cases {
437		t.Run(tc.name, func(t *testing.T) {
438			clk := &fakeClock{}
439			h := &captureHandler{}
440			r := NewRouter(clk, h, nil)
441			for _, read := range tc.reads {
442				r.Route(read)
443			}
444			require.Equal(t, tc.wantActions, h.actions)
445			require.Equal(t, tc.wantForwards, h.forwards)
446		})
447	}
448}
449
450func TestRouterPassesThroughBareArrows(t *testing.T) {
451	for _, in := range [][]byte{
452		{ESC, '[', 'A'},
453		{ESC, '[', 'B'},
454		{ESC, '[', 'C'},
455		{ESC, '[', 'D'},
456	} {
457		t.Run(string(in), func(t *testing.T) {
458			clk := &fakeClock{}
459			h := &captureHandler{}
460			NewRouter(clk, h, nil).Route(in)
461			require.Empty(t, h.actions)
462			require.Equal(t, [][]byte{in}, h.forwards)
463			require.Empty(t, clk.timers)
464		})
465	}
466}
467
468func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
469	clk := &fakeClock{}
470	h := &captureHandler{}
471	r := NewRouter(clk, h, nil)
472	r.Route([]byte{ESC})
473	require.Len(t, clk.timers, 1)
474	require.Equal(t, ESCDelay, clk.timers[0].d)
475	require.Empty(t, h.forwards)
476
477	r.Route([]byte{' '})
478	require.True(t, clk.timers[0].stopped)
479	require.Equal(t, []Action{ActionOpenPalette}, h.actions)
480	require.Empty(t, h.forwards)
481}
482
483func TestRouterForwardsBareT(t *testing.T) {
484	clk := &fakeClock{}
485	h := &captureHandler{}
486	NewRouter(clk, h, nil).Route([]byte{'t'})
487	require.Empty(t, h.actions)
488	require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
489}
490
491func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
492	cases := []struct {
493		name        string
494		secondInput []byte
495		want        []byte
496	}{
497		{name: "CSI up", secondInput: []byte("[A"), want: []byte{ESC, '[', 'A'}},
498		{name: "SS3 function key", secondInput: []byte("OP"), want: []byte{ESC, 'O', 'P'}},
499	}
500
501	for _, tc := range cases {
502		t.Run(tc.name, func(t *testing.T) {
503			clk := &fakeClock{}
504			h := &captureHandler{}
505			r := NewRouter(clk, h, nil)
506			r.Route([]byte{ESC})
507			require.Len(t, clk.timers, 1)
508			require.Empty(t, h.forwards)
509
510			r.Route(tc.secondInput)
511
512			require.True(t, clk.timers[0].stopped)
513			require.Empty(t, h.actions)
514			require.Equal(t, [][]byte{tc.want[:2], tc.want[2:]}, h.forwards)
515		})
516	}
517}
518
519func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
520	clk := &fakeClock{}
521	h := &captureHandler{}
522	r := NewRouter(clk, h, nil)
523	r.Route([]byte{ESC})
524
525	r.Route([]byte{'[', 'A', ESC, ' '})
526
527	require.True(t, clk.timers[0].stopped)
528	require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
529	require.Equal(t, []Action{ActionOpenPalette}, h.actions)
530}
531
532func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
533	clk := &fakeClock{}
534	h := &captureHandler{}
535	r := NewRouter(clk, h, nil)
536
537	before := retainESCWaiters()
538	r.Route([]byte{ESC})
539	require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)
540
541	r.Route([]byte{' '})
542	require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
543	require.True(t, clk.timers[0].stopped)
544	require.Equal(t, []Action{ActionOpenPalette}, h.actions)
545	require.Empty(t, h.forwards)
546}
547
548func retainESCWaiters() int {
549	buf := make([]byte, 1<<20)
550	n := runtime.Stack(buf, true)
551	return strings.Count(string(buf[:n]), "github.com/bnema/vev/internal/usecase/keys.(*Router).retainESC.func1")
552}
553
554func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
555	clk := &fakeClock{}
556	h := &captureHandler{notify: make(chan struct{}, 1)}
557	r := NewRouter(clk, h, nil)
558	r.Route([]byte{ESC})
559	clk.timers[0].fire()
560	select {
561	case <-h.notify:
562	case <-time.After(time.Second):
563		t.Fatal("timed out waiting for flushed ESC")
564	}
565	require.Equal(t, [][]byte{{ESC}}, h.forwards)
566	require.Empty(t, h.actions)
567}
568
569func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
570	clk := &fakeClock{}
571	h := &captureHandler{}
572	r := NewRouter(clk, h, nil)
573	r.Route([]byte{ESC})
574	r.Route([]byte{'z'})
575	require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
576	require.Empty(t, h.actions)
577}
578
579// pasteBindingLookalikes are content bytes that, seen outside a paste, each map
580// to a binding: Alt-j, Alt-space, Alt-1, and an Alt-arrow CSI.
581var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
582
583func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
584	clk := &fakeClock{}
585	h := &captureHandler{}
586	r := NewRouter(clk, h, nil)
587
588	paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
589	r.Route(paste)
590
591	require.Empty(t, h.actions, "paste content must not fire any binding")
592	require.Equal(t, [][]byte{paste}, h.forwards, "the whole paste must be forwarded byte-identical")
593	require.Empty(t, clk.timers, "a fully bracketed paste retains no trailing ESC")
594}
595
596func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
597	clk := &fakeClock{}
598	h := &captureHandler{}
599	r := NewRouter(clk, h, nil)
600
601	r.Route(pasteBindingLookalikes)
602
603	require.Equal(t, []Action{
604		ActionFocusPaneDown, // Alt-j
605		ActionOpenPalette,   // Alt-space
606		ActionSwitchTab1,    // Alt-1
607		ActionFocusPaneUp,   // Alt-arrow up
608	}, h.actions)
609	require.Empty(t, h.forwards)
610}
611
612func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
613	clk := &fakeClock{}
614	h := &captureHandler{}
615	r := NewRouter(clk, h, nil)
616
617	// Opening marker + content in one frame, closing marker in the next: with
618	// no closing marker in-frame the router keeps its historical per-byte
619	// routing, forwarding each frame's bytes as they arrive.
620	r.Route([]byte("\x1b[200~hello"))
621	r.Route([]byte("\x1b[201~"))
622
623	require.Empty(t, h.actions)
624	require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards)
625}
626
627func TestRouterKittyKeyboardEvents(t *testing.T) {
628	tests := []struct {
629		name         string
630		reads        []string
631		wantActions  []Action
632		wantForwards []string
633	}{
634		{name: "ctrl 1", reads: []string{"\x1b[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
635		{name: "ctrl 9", reads: []string{"\x1b[57;5u"}, wantActions: []Action{ActionSwitchRecent9}},
636		{name: "azerty ctrl 2 base key", reads: []string{"\x1b[233::50;5u"}, wantActions: []Action{ActionSwitchRecent2}},
637		{name: "ctrl 0 forwarded", reads: []string{"\x1b[48;5u"}, wantForwards: []string{"\x1b[48;5u"}},
638		{name: "ctrl shift 1 forwarded", reads: []string{"\x1b[49;6u"}, wantForwards: []string{"\x1b[49;6u"}},
639		{name: "ctrl 1 release ignored", reads: []string{"\x1b[49;5:3u"}, wantForwards: []string{"\x1b[49;5:3u"}},
640		{name: "alt j binding", reads: []string{"\x1b[106;3u"}, wantActions: []Action{ActionFocusPaneDown}},
641		{name: "alt space binding", reads: []string{"\x1b[32;3u"}, wantActions: []Action{ActionOpenPalette}},
642		{name: "alt azerty 1 via symbol", reads: []string{"\x1b[38::49;3u"}, wantActions: []Action{ActionSwitchTab1}},
643		{name: "alt unbound forwarded", reads: []string{"\x1b[122;3u"}, wantForwards: []string{"\x1b[122;3u"}},
644		{name: "ctrl a forwarded", reads: []string{"\x1b[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
645		{name: "text around", reads: []string{"ab\x1b[49;5ucd"}, wantActions: []Action{ActionSwitchRecent1}, wantForwards: []string{"ab", "cd"}},
646		{name: "split across reads", reads: []string{"\x1b[49", ";5u"}, wantActions: []Action{ActionSwitchRecent1}},
647		{name: "split after esc", reads: []string{"\x1b", "[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
648		{name: "unbound split after esc stays whole", reads: []string{"\x1b", "[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
649		{name: "unbound split mid sequence stays whole", reads: []string{"\x1b[97", ";5ux"}, wantForwards: []string{"\x1b[97;5u", "x"}},
650		{name: "alt shift h uses shifted key", reads: []string{"\x1b[104:72;4u"}, wantForwards: []string{"\x1b[104:72;4u"}},
651		{name: "inside paste untouched", reads: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}, wantForwards: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}},
652	}
653	for _, tt := range tests {
654		t.Run(tt.name, func(t *testing.T) {
655			clk := &fakeClock{}
656			h := &captureHandler{}
657			r := NewRouter(clk, h, nil)
658			r.SetKittyKeyboard(true)
659			for _, read := range tt.reads {
660				r.Route([]byte(read))
661			}
662			require.Equal(t, tt.wantActions, h.actions)
663			var forwards []string
664			for _, f := range h.forwards {
665				forwards = append(forwards, string(f))
666			}
667			require.Equal(t, tt.wantForwards, forwards)
668			assertRouterPendingCleared(t, r)
669		})
670	}
671}
672
673func TestRouterKittyKeyboardAltShiftBindings(t *testing.T) {
674	custom := DefaultBindings().clone()
675	custom.bind(ActionConsumeOrExpelPaneLeft, KeySpec{altRunes: []rune{'H'}})
676	custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'?'}})
677	var bindings atomic.Pointer[Bindings]
678	bindings.Store(custom)
679	tests := []struct {
680		name string
681		in   string
682		want Action
683	}{
684		{name: "alt shift h with shifted key", in: "\x1b[104:72;4u", want: ActionConsumeOrExpelPaneLeft},
685		{name: "alt shift h without shifted key", in: "\x1b[104;4u", want: ActionConsumeOrExpelPaneLeft},
686		{name: "alt shift slash", in: "\x1b[47:63;4u", want: ActionEqualizePanes},
687	}
688	for _, tt := range tests {
689		t.Run(tt.name, func(t *testing.T) {
690			h := &captureHandler{}
691			r := NewRouter(&fakeClock{}, h, &bindings)
692			r.SetKittyKeyboard(true)
693			r.Route([]byte(tt.in))
694			require.Equal(t, []Action{tt.want}, h.actions)
695			require.Empty(t, h.forwards)
696		})
697	}
698}
699
700func TestRouterIgnoresKittyKeysUnlessEnabled(t *testing.T) {
701	h := &captureHandler{}
702	r := NewRouter(&fakeClock{}, h, nil)
703	r.Route([]byte("\x1b[49;5u"))
704	require.Empty(t, h.actions)
705	require.Equal(t, [][]byte{[]byte("\x1b[49;5u")}, h.forwards)
706}

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