v0.31.0 bf0756b

vimkeys_dispatch_test.go RawHistoryFolder

1package input
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/domain/vimkeys"
10	"github.com/bnema/puregotk/v4/gdk"
11)
12
13type stubSequenceTimer struct {
14	onStop func()
15}
16
17func (s stubSequenceTimer) Stop() bool {
18	if s.onStop != nil {
19		s.onStop()
20	}
21	return true
22}
23
24func vimModeSequenceWorkspace(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig {
25	ws := newTestWorkspace()
26	ws.VimMode = entity.VimModeConfig{
27		ActivationShortcut:          "ctrl+y",
28		TimeoutMilliseconds:         0,
29		SequenceTimeoutMilliseconds: 500,
30		Actions:                     actions,
31	}
32	return ws
33}
34
35func enterVimMode(t *testing.T, h *KeyboardHandler) {
36	t.Helper()
37	if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
38		t.Fatal("ctrl+y should enter vim mode")
39	}
40	if h.Mode() != ModeVim {
41		t.Fatalf("mode = %v, want ModeVim", h.Mode())
42	}
43}
44
45func capturePending(h *KeyboardHandler) *[]string {
46	pending := &[]string{}
47	h.SetOnPendingSequenceChange(func(p string) {
48		*pending = append(*pending, p)
49	})
50	return pending
51}
52
53func captureSequenceActions(h *KeyboardHandler) *[]struct {
54	action string
55	count  int
56} {
57	actions := &[]struct {
58		action string
59		count  int
60	}{}
61	h.SetOnSequenceAction(func(action string, count int) {
62		*actions = append(*actions, struct {
63			action string
64			count  int
65		}{action: action, count: count})
66	})
67	return actions
68}
69
70func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
71	state := newVimModeSequenceState(nil, 0)
72	if state.matcher == nil {
73		t.Fatal("matcher is nil")
74	}
75	if got := state.matcher.Pending(); got != "" {
76		t.Fatalf("Pending() = %q, want empty", got)
77	}
78	if state.matcher.Ambiguous() {
79		t.Fatal("Ambiguous() = true, want false")
80	}
81	result := state.matcher.Feed(vimkeys.Key{Sym: "x"})
82	if result.Kind != vimkeys.ResultInvalid {
83		t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid)
84	}
85}
86
87func TestTrieOwnsBinding(t *testing.T) {
88	cases := map[string]bool{
89		"]]":      true,
90		"gi":      true,
91		"yah":     true,
92		"gO":      true,
93		"<C-d>":   true,
94		"j":       false,
95		"shift+j": false,
96		"}":       false,
97		"enter":   false,
98		"escape":  false,
99		"<Nope>":  false,
100	}
101
102	for raw, wantOwned := range cases {
103		t.Run(raw, func(t *testing.T) {
104			seq, owned := trieOwnsBinding(raw)
105			if owned != wantOwned {
106				t.Fatalf("trieOwnsBinding(%q) owned = %v, want %v (seq=%v)", raw, owned, wantOwned, seq)
107			}
108			if !wantOwned {
109				return
110			}
111			if len(seq) == 0 {
112				t.Fatalf("trieOwnsBinding(%q) returned empty sequence", raw)
113			}
114		})
115	}
116}
117
118func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
119	tests := []struct {
120		raw  string
121		want vimkeys.Sequence
122	}{
123		{raw: "}", want: vimkeys.Sequence{{Sym: "}"}}},
124		{raw: "<C-d>", want: vimkeys.Sequence{{Sym: "d", Mods: vimkeys.ModCtrl}}},
125		{raw: "<CR>", want: vimkeys.Sequence{{Sym: "CR"}}},
126		{raw: "J", want: vimkeys.Sequence{{Sym: "j", Mods: vimkeys.ModShift}}},
127		{raw: "enter", want: vimkeys.Sequence{{Sym: "CR"}}},
128		{raw: "escape", want: vimkeys.Sequence{{Sym: "Esc"}}},
129	}
130
131	for _, tt := range tests {
132		t.Run(tt.raw, func(t *testing.T) {
133			got, err := vimkeys.ParseBinding(tt.raw)
134			if err != nil {
135				t.Fatalf("ParseBinding(%q) error = %v", tt.raw, err)
136			}
137			if !got.Equal(tt.want) {
138				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.raw, got, tt.want)
139			}
140			_, owned := trieOwnsBinding(tt.raw)
141			switch tt.raw {
142			case "<C-d>", "<CR>":
143				if !owned {
144					t.Fatalf("trieOwnsBinding(%q) = false, want true", tt.raw)
145				}
146			default:
147				if owned {
148					t.Fatalf("trieOwnsBinding(%q) = true, want false", tt.raw)
149				}
150			}
151		})
152	}
153}
154
155func TestBuildVimModeTrie(t *testing.T) {
156	cfg := &entity.VimModeConfig{
157		Actions: map[string]entity.ActionBinding{
158			"vim-scroll-down": {Keys: []string{"j"}},
159			"heading-next":    {Keys: []string{"]]"}},
160			"focus-input":     {Keys: []string{"gi"}},
161			"yank-section":    {Keys: []string{"yah"}},
162			"outline":         {Keys: []string{"gO"}},
163			"half-page-down":  {Keys: []string{"<C-d>"}},
164			"confirm":         {Keys: []string{"enter"}},
165			"cancel":          {Keys: []string{"escape"}},
166		},
167	}
168
169	trie, owned := buildVimModeTrie(cfg)
170	if trie == nil {
171		t.Fatal("buildVimModeTrie returned nil trie")
172	}
173
174	wantOwned := map[string]bool{
175		"]]":    true,
176		"gi":    true,
177		"yah":   true,
178		"gO":    true,
179		"<C-d>": true,
180	}
181	for key, want := range wantOwned {
182		if owned[key] != want {
183			t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
184		}
185	}
186	for _, legacy := range []string{"j", "enter", "escape"} {
187		if owned[legacy] {
188			t.Fatalf("legacy binding %q should not be owned", legacy)
189		}
190	}
191
192	seq, err := vimkeys.ParseBinding("]]")
193	if err != nil {
194		t.Fatalf("ParseBinding(]]) error = %v", err)
195	}
196	node, ok := trie.Walk(seq)
197	if !ok || !node.Exact || node.Action != "heading-next" {
198		t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
199	}
200
201	seq, err = vimkeys.ParseBinding("gi")
202	if err != nil {
203		t.Fatalf("ParseBinding(gi) error = %v", err)
204	}
205	node, ok = trie.Walk(seq)
206	if !ok || !node.Exact || node.Action != "focus-input" {
207		t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok)
208	}
209}
210
211func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
212	// Same sequence under two raw aliases; first sorted action wins Insert.
213	// Losing raw key must not be marked owned (insert failed).
214	cfg := &entity.VimModeConfig{
215		Actions: map[string]entity.ActionBinding{
216			"zzz-later":       {Keys: []string{"<c-d>"}},
217			"aaa-first":       {Keys: []string{"<C-d>"}},
218			"vim-scroll-down": {Keys: []string{"j"}},
219		},
220	}
221
222	trie, owned := buildVimModeTrie(cfg)
223	if !owned["<C-d>"] {
224		t.Fatal("winning raw key <C-d> should be owned after successful Insert")
225	}
226	if owned["<c-d>"] {
227		t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
228	}
229
230	seq, err := vimkeys.ParseBinding("<C-d>")
231	if err != nil {
232		t.Fatalf("ParseBinding(<C-d>) error = %v", err)
233	}
234	node, ok := trie.Walk(seq)
235	if !ok || !node.Exact || node.Action != "aaa-first" {
236		t.Fatalf("Walk(<C-d>) = (%#v, %v), want exact aaa-first", node, ok)
237	}
238}
239
240func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
241	t.Helper()
242
243	workspace := &entity.WorkspaceConfig{
244		VimMode: entity.VimModeConfig{
245			ActivationShortcut: "ctrl+y",
246			Actions: map[string]entity.ActionBinding{
247				"vim-scroll-down":      {Keys: []string{"j"}},
248				"vim-scroll-down-fast": {Keys: []string{"shift+j"}},
249				"confirm":              {Keys: []string{"enter"}},
250				"cancel":               {Keys: []string{"escape"}},
251				"heading-next":         {Keys: []string{"]]"}},
252				"yank-section":         {Keys: []string{"yah"}},
253				"outline":              {Keys: []string{"gO"}},
254				"half-page-down":       {Keys: []string{"<C-d>"}},
255			},
256		},
257	}
258
259	set := NewShortcutSet(context.Background(), workspace, nil)
260	if set.VimModeSequences() == nil {
261		t.Fatal("VimModeSequences() is nil")
262	}
263	return set
264}
265
266func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
267	t.Helper()
268
269	jBinding, jOK := ParseKeyString("j")
270	if !jOK {
271		t.Fatal("ParseKeyString(j) failed")
272	}
273	if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
274		t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
275	}
276
277	enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
278	if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode {
279		t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action)
280	}
281
282	escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
283	if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
284		t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
285	}
286}
287
288func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
289	t.Helper()
290
291	for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
292		if binding, parsed := ParseKeyString(key); parsed {
293			if action, found := set.VimMode[binding]; found {
294				t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
295			}
296		}
297	}
298}
299
300func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
301	t.Helper()
302
303	seq, err := vimkeys.ParseBinding("yah")
304	if err != nil {
305		t.Fatalf("ParseBinding(yah) error = %v", err)
306	}
307	node, walked := set.VimModeSequences().Walk(seq)
308	if !walked || !node.Exact || node.Action != "yank-section" {
309		t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
310	}
311}
312
313func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
314	set := vimModeSequencesOwnershipFixture(t)
315
316	t.Run("legacy bindings", func(t *testing.T) {
317		assertLegacyVimModeBindings(t, set)
318	})
319	t.Run("owned bindings absent from legacy table", func(t *testing.T) {
320		assertOwnedBindingsAbsentFromVimMode(t, set)
321	})
322	t.Run("trie resolves owned sequence", func(t *testing.T) {
323		assertVimModeTrieYankSection(t, set)
324	})
325}
326
327func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
328	ctx := context.Background()
329	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
330		"heading-next":    {Keys: []string{"]]"}},
331		"vim-scroll-down": {Keys: []string{"j"}},
332		"cancel":          {Keys: []string{"escape"}},
333	})
334	h := NewKeyboardHandler(ctx, ws, newTestSession())
335	pending := capturePending(h)
336	actions := captureSequenceActions(h)
337	enterVimMode(t, h)
338
339	if !h.handleKeyPress(uint(']'), 0, 0) {
340		t.Fatal("] prefix should be consumed")
341	}
342	if h.PendingSequence() != "]" {
343		t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
344	}
345	if len(*pending) != 1 || (*pending)[0] != "]" {
346		t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
347	}
348
349	if !h.handleKeyPress(uint(']'), 0, 0) {
350		t.Fatal("]] should be consumed")
351	}
352	if h.PendingSequence() != "" {
353		t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
354	}
355	if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
356		t.Fatalf("actions = %#v, want heading-next count 0", *actions)
357	}
358	if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
359		t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
360	}
361}
362
363func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
364	ctx := context.Background()
365	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
366		"yank-section":    {Keys: []string{"yah"}},
367		"vim-scroll-down": {Keys: []string{"j"}},
368	})
369	h := NewKeyboardHandler(ctx, ws, newTestSession())
370	pending := capturePending(h)
371	actions := captureSequenceActions(h)
372	enterVimMode(t, h)
373
374	for _, step := range []struct {
375		key  uint
376		want string
377	}{
378		{uint('y'), "y"},
379		{uint('a'), "ya"},
380		{uint('h'), ""},
381	} {
382		if !h.handleKeyPress(step.key, 0, 0) {
383			t.Fatalf("key %c should be consumed", step.key)
384		}
385		if got := h.PendingSequence(); got != step.want {
386			t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
387		}
388	}
389	if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
390		t.Fatalf("actions = %#v, want yank-section", *actions)
391	}
392	if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
393		t.Fatalf("pending = %#v, want y then ya", *pending)
394	}
395}
396
397func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
398	ctx := context.Background()
399	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
400		"heading-next":    {Keys: []string{"]]"}},
401		"vim-scroll-down": {Keys: []string{"j"}},
402	})
403	h := NewKeyboardHandler(ctx, ws, newTestSession())
404	var scrollCalls int
405	h.SetOnAction(func(_ context.Context, action Action) error {
406		if action == ActionVimScrollDown {
407			scrollCalls++
408		}
409		return nil
410	})
411	enterVimMode(t, h)
412
413	// Unknown sequence key falls through; legacy j still works.
414	if !h.handleKeyPress(uint('j'), 0, 0) {
415		t.Fatal("legacy j should be handled")
416	}
417	if scrollCalls != 1 {
418		t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
419	}
420	if h.PendingSequence() != "" {
421		t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
422	}
423}
424
425func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
426	ctx := context.Background()
427	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
428		"heading-next": {Keys: []string{"]]"}},
429	})
430	h := NewKeyboardHandler(ctx, ws, newTestSession())
431	pending := capturePending(h)
432
433	if h.feedVimModeSequence(uint(']'), 0) {
434		t.Fatal("feed outside vim mode should return false")
435	}
436	if len(*pending) != 0 {
437		t.Fatalf("pending = %#v, want none", *pending)
438	}
439}
440
441func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
442	ctx := context.Background()
443	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
444		"yank-section": {Keys: []string{"yah"}},
445		"cancel":       {Keys: []string{"escape"}},
446	})
447	h := NewKeyboardHandler(ctx, ws, newTestSession())
448	pending := capturePending(h)
449	enterVimMode(t, h)
450
451	if !h.handleKeyPress(uint('y'), 0, 0) {
452		t.Fatal("y should be consumed")
453	}
454	before := len(*pending)
455
456	if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
457		t.Fatal("escape should exit")
458	}
459	if h.Mode() != ModeNormal {
460		t.Fatalf("mode = %v, want ModeNormal", h.Mode())
461	}
462	if h.PendingSequence() != "" {
463		t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
464	}
465	if len(*pending) != before {
466		t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
467	}
468}
469
470func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
471	ctx := context.Background()
472	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
473		"heading-next": {Keys: []string{"]]"}},
474	})
475	h := NewKeyboardHandler(ctx, ws, newTestSession())
476	actions := captureSequenceActions(h)
477	enterVimMode(t, h)
478
479	if !h.handleKeyPress(uint('2'), 0, 0) {
480		t.Fatal("count digit should be consumed")
481	}
482	if h.PendingSequence() != "2" {
483		t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
484	}
485	if !h.handleKeyPress(uint(']'), 0, 0) {
486		t.Fatal("first ] after count should be consumed")
487	}
488	if !h.handleKeyPress(uint(']'), 0, 0) {
489		t.Fatal("2]] should complete")
490	}
491	if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
492		t.Fatalf("actions = %#v, want heading-next count 2", *actions)
493	}
494}
495
496func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
497	ctx := context.Background()
498	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
499		"bracket-c":  {Keys: []string{"]c"}},
500		"bracket-cc": {Keys: []string{"]cc"}},
501	})
502	h := NewKeyboardHandler(ctx, ws, newTestSession())
503	actions := captureSequenceActions(h)
504
505	var fired func()
506	var stops int
507	h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
508		if d != 500*time.Millisecond {
509			t.Fatalf("ambiguity timeout = %v, want 500ms", d)
510		}
511		fired = fn
512		return stubSequenceTimer{onStop: func() { stops++ }}
513	}
514	var queued []func()
515	h.SetSequenceMainThreadScheduler(func(fn func()) {
516		queued = append(queued, fn)
517	})
518
519	enterVimMode(t, h)
520	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
521		t.Fatal("]c prefix should be consumed")
522	}
523	if !h.seq.matcher.Ambiguous() {
524		t.Fatal("]c should be ambiguous against ]cc")
525	}
526	if fired == nil {
527		t.Fatal("expected ambiguity timer arm")
528	}
529
530	fired() // timer expiry queues resolve on scheduler
531	if len(queued) != 1 {
532		t.Fatalf("queued resolves = %d, want 1", len(queued))
533	}
534	queued[0]()
535	if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
536		t.Fatalf("actions = %#v, want bracket-c", *actions)
537	}
538	if h.PendingSequence() != "" {
539		t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
540	}
541	_ = stops
542}
543
544func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
545	ctx := context.Background()
546	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
547		"bracket-c":  {Keys: []string{"]c"}},
548		"bracket-cc": {Keys: []string{"]cc"}},
549	})
550	ws.VimMode.SequenceTimeoutMilliseconds = 0
551	h := NewKeyboardHandler(ctx, ws, newTestSession())
552	actions := captureSequenceActions(h)
553
554	var armed time.Duration = -1
555	h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
556		armed = d
557		fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
558		return stubSequenceTimer{}
559	}
560	var queued []func()
561	h.SetSequenceMainThreadScheduler(func(fn func()) {
562		queued = append(queued, fn)
563	})
564
565	enterVimMode(t, h)
566	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
567		t.Fatal("]c should be consumed")
568	}
569	if armed != 0 {
570		t.Fatalf("armed timeout = %v, want 0", armed)
571	}
572	if len(queued) != 1 {
573		t.Fatalf("queued = %d, want 1", len(queued))
574	}
575	queued[0]()
576	if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
577		t.Fatalf("actions = %#v, want bracket-c", *actions)
578	}
579}
580
581func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
582	ctx := context.Background()
583	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
584		"bracket-c":  {Keys: []string{"]c"}},
585		"bracket-cc": {Keys: []string{"]cc"}},
586	})
587	h := NewKeyboardHandler(ctx, ws, newTestSession())
588	actions := captureSequenceActions(h)
589
590	var fired func()
591	var stops int
592	h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
593		fired = fn
594		return stubSequenceTimer{onStop: func() { stops++ }}
595	}
596	h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
597
598	enterVimMode(t, h)
599	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
600		t.Fatal("]c should be consumed")
601	}
602	if fired == nil {
603		t.Fatal("expected timer")
604	}
605	if !h.handleKeyPress(uint('c'), 0, 0) {
606		t.Fatal("]cc should complete")
607	}
608	if stops == 0 {
609		t.Fatal("next key must Stop ambiguity timer")
610	}
611	if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
612		t.Fatalf("actions = %#v, want bracket-cc", *actions)
613	}
614	// Stale timer must no-op.
615	fired()
616	if len(*actions) != 1 {
617		t.Fatalf("stale timer mutated actions: %#v", *actions)
618	}
619}
620
621func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
622	ctx := context.Background()
623	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
624		"bracket-c":    {Keys: []string{"]c"}},
625		"bracket-cc":   {Keys: []string{"]cc"}},
626		"yank-section": {Keys: []string{"yah"}},
627	})
628	h := NewKeyboardHandler(ctx, ws, newTestSession())
629	actions := captureSequenceActions(h)
630	pending := capturePending(h)
631
632	var fired func()
633	h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
634		fired = fn
635		return stubSequenceTimer{}
636	}
637	var queued []func()
638	h.SetSequenceMainThreadScheduler(func(fn func()) {
639		queued = append(queued, fn)
640	})
641
642	enterVimMode(t, h)
643	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
644		t.Fatal("]c should be consumed")
645	}
646	fired() // enqueue stale resolve
647	if len(queued) != 1 {
648		t.Fatalf("queued = %d, want 1", len(queued))
649	}
650	stale := queued[0]
651	queued = nil
652
653	// Interleave: feed/reset/reload before stale closure runs.
654	h.ResetPendingSequence()
655	if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
656		t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
657	}
658	stale()
659	if len(*actions) != 0 {
660		t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
661	}
662
663	// Reload path also invalidates queued resolve.
664	fired = nil
665	queued = nil
666	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
667		t.Fatal("]c again")
668	}
669	fired()
670	stale = queued[0]
671	h.ReloadShortcuts(ctx, ws, newTestSession())
672	stale()
673	if len(*actions) != 0 {
674		t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
675	}
676}
677
678func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
679	ctx := context.Background()
680	wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
681		"action-a":        {Keys: []string{"yah"}},
682		"vim-scroll-down": {Keys: []string{"j"}},
683	})
684	h := NewKeyboardHandler(ctx, wsA, newTestSession())
685	actions := captureSequenceActions(h)
686	enterVimMode(t, h)
687
688	if !h.handleKeyPress(uint('y'), 0, 0) {
689		t.Fatal("y should pending under trie A")
690	}
691	if h.PendingSequence() != "y" {
692		t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
693	}
694
695	wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
696		"action-b":        {Keys: []string{"gO"}},
697		"vim-scroll-down": {Keys: []string{"j"}},
698	})
699	h.ReloadShortcuts(ctx, wsB, newTestSession())
700	if h.PendingSequence() != "" {
701		t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
702	}
703
704	// Old A sequence must not complete; fall through / invalid.
705	if h.feedVimModeSequence(uint('a'), 0) {
706		t.Fatal("stale A continuation must not be consumed after reload")
707	}
708	if len(*actions) != 0 {
709		t.Fatalf("no A action after reload, got %#v", *actions)
710	}
711
712	// New B sequence works.
713	if !h.handleKeyPress(uint('g'), 0, 0) {
714		t.Fatal("g should pending under trie B")
715	}
716	if h.PendingSequence() != "g" {
717		t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
718	}
719	if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
720		t.Fatal("gO should complete under trie B")
721	}
722	if len(*actions) != 1 || (*actions)[0].action != "action-b" {
723		t.Fatalf("actions = %#v, want action-b", *actions)
724	}
725}

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 8

Tags 42

↑↓ moveenter openesc close