v0.32.0 0617278

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 != ActionVimConfirm {
279		t.Fatalf("legacy Vim confirm 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_ConfiguredConfirmActivatesAndExits(t *testing.T) {
364	ctx := context.Background()
365	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
366		"confirm": {Keys: []string{"zz"}},
367	})
368	h := NewKeyboardHandler(ctx, ws, newTestSession())
369	actions := captureSequenceActions(h)
370	enterVimMode(t, h)
371
372	if !h.handleKeyPress(uint('z'), 0, 0) {
373		t.Fatal("configured confirm prefix should be consumed")
374	}
375	if !h.handleKeyPress(uint('z'), 0, 0) {
376		t.Fatal("configured confirm completion should be consumed")
377	}
378	if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 {
379		t.Fatalf("actions = %#v, want confirm count 0", *actions)
380	}
381	if h.Mode() != ModeNormal {
382		t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode())
383	}
384}
385
386func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
387	ctx := context.Background()
388	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
389		"yank-section":    {Keys: []string{"yah"}},
390		"vim-scroll-down": {Keys: []string{"j"}},
391	})
392	h := NewKeyboardHandler(ctx, ws, newTestSession())
393	pending := capturePending(h)
394	actions := captureSequenceActions(h)
395	enterVimMode(t, h)
396
397	for _, step := range []struct {
398		key  uint
399		want string
400	}{
401		{uint('y'), "y"},
402		{uint('a'), "ya"},
403		{uint('h'), ""},
404	} {
405		if !h.handleKeyPress(step.key, 0, 0) {
406			t.Fatalf("key %c should be consumed", step.key)
407		}
408		if got := h.PendingSequence(); got != step.want {
409			t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
410		}
411	}
412	if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
413		t.Fatalf("actions = %#v, want yank-section", *actions)
414	}
415	if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
416		t.Fatalf("pending = %#v, want y then ya", *pending)
417	}
418}
419
420func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
421	ctx := context.Background()
422	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
423		"heading-next":    {Keys: []string{"]]"}},
424		"vim-scroll-down": {Keys: []string{"j"}},
425	})
426	h := NewKeyboardHandler(ctx, ws, newTestSession())
427	var scrollCalls int
428	h.SetOnAction(func(_ context.Context, action Action) error {
429		if action == ActionVimScrollDown {
430			scrollCalls++
431		}
432		return nil
433	})
434	enterVimMode(t, h)
435
436	// Unknown sequence key falls through; legacy j still works.
437	if !h.handleKeyPress(uint('j'), 0, 0) {
438		t.Fatal("legacy j should be handled")
439	}
440	if scrollCalls != 1 {
441		t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
442	}
443	if h.PendingSequence() != "" {
444		t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
445	}
446}
447
448func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
449	ctx := context.Background()
450	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
451		"heading-next": {Keys: []string{"]]"}},
452	})
453	h := NewKeyboardHandler(ctx, ws, newTestSession())
454	pending := capturePending(h)
455
456	if h.feedVimModeSequence(uint(']'), 0) {
457		t.Fatal("feed outside vim mode should return false")
458	}
459	if len(*pending) != 0 {
460		t.Fatalf("pending = %#v, want none", *pending)
461	}
462}
463
464func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
465	ctx := context.Background()
466	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
467		"yank-section": {Keys: []string{"yah"}},
468		"cancel":       {Keys: []string{"escape"}},
469	})
470	h := NewKeyboardHandler(ctx, ws, newTestSession())
471	pending := capturePending(h)
472	enterVimMode(t, h)
473
474	if !h.handleKeyPress(uint('y'), 0, 0) {
475		t.Fatal("y should be consumed")
476	}
477	before := len(*pending)
478
479	if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
480		t.Fatal("escape should exit")
481	}
482	if h.Mode() != ModeNormal {
483		t.Fatalf("mode = %v, want ModeNormal", h.Mode())
484	}
485	if h.PendingSequence() != "" {
486		t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
487	}
488	if len(*pending) != before {
489		t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
490	}
491}
492
493func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
494	ctx := context.Background()
495	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
496		"heading-next": {Keys: []string{"]]"}},
497	})
498	h := NewKeyboardHandler(ctx, ws, newTestSession())
499	actions := captureSequenceActions(h)
500	enterVimMode(t, h)
501
502	if !h.handleKeyPress(uint('2'), 0, 0) {
503		t.Fatal("count digit should be consumed")
504	}
505	if h.PendingSequence() != "2" {
506		t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
507	}
508	if !h.handleKeyPress(uint(']'), 0, 0) {
509		t.Fatal("first ] after count should be consumed")
510	}
511	if !h.handleKeyPress(uint(']'), 0, 0) {
512		t.Fatal("2]] should complete")
513	}
514	if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
515		t.Fatalf("actions = %#v, want heading-next count 2", *actions)
516	}
517}
518
519func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
520	ctx := context.Background()
521	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
522		"bracket-c":  {Keys: []string{"]c"}},
523		"bracket-cc": {Keys: []string{"]cc"}},
524	})
525	h := NewKeyboardHandler(ctx, ws, newTestSession())
526	actions := captureSequenceActions(h)
527
528	var fired func()
529	var stops int
530	h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
531		if d != 500*time.Millisecond {
532			t.Fatalf("ambiguity timeout = %v, want 500ms", d)
533		}
534		fired = fn
535		return stubSequenceTimer{onStop: func() { stops++ }}
536	}
537	var queued []func()
538	h.SetSequenceMainThreadScheduler(func(fn func()) {
539		queued = append(queued, fn)
540	})
541
542	enterVimMode(t, h)
543	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
544		t.Fatal("]c prefix should be consumed")
545	}
546	if !h.seq.matcher.Ambiguous() {
547		t.Fatal("]c should be ambiguous against ]cc")
548	}
549	if fired == nil {
550		t.Fatal("expected ambiguity timer arm")
551	}
552
553	fired() // timer expiry queues resolve on scheduler
554	if len(queued) != 1 {
555		t.Fatalf("queued resolves = %d, want 1", len(queued))
556	}
557	queued[0]()
558	if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
559		t.Fatalf("actions = %#v, want bracket-c", *actions)
560	}
561	if h.PendingSequence() != "" {
562		t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
563	}
564	_ = stops
565}
566
567func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
568	ctx := context.Background()
569	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
570		"bracket-c":  {Keys: []string{"]c"}},
571		"bracket-cc": {Keys: []string{"]cc"}},
572	})
573	ws.VimMode.SequenceTimeoutMilliseconds = 0
574	h := NewKeyboardHandler(ctx, ws, newTestSession())
575	actions := captureSequenceActions(h)
576
577	var armed time.Duration = -1
578	h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
579		armed = d
580		fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
581		return stubSequenceTimer{}
582	}
583	var queued []func()
584	h.SetSequenceMainThreadScheduler(func(fn func()) {
585		queued = append(queued, fn)
586	})
587
588	enterVimMode(t, h)
589	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
590		t.Fatal("]c should be consumed")
591	}
592	if armed != 0 {
593		t.Fatalf("armed timeout = %v, want 0", armed)
594	}
595	if len(queued) != 1 {
596		t.Fatalf("queued = %d, want 1", len(queued))
597	}
598	queued[0]()
599	if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
600		t.Fatalf("actions = %#v, want bracket-c", *actions)
601	}
602}
603
604func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
605	ctx := context.Background()
606	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
607		"bracket-c":  {Keys: []string{"]c"}},
608		"bracket-cc": {Keys: []string{"]cc"}},
609	})
610	h := NewKeyboardHandler(ctx, ws, newTestSession())
611	actions := captureSequenceActions(h)
612
613	var fired func()
614	var stops int
615	h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
616		fired = fn
617		return stubSequenceTimer{onStop: func() { stops++ }}
618	}
619	h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
620
621	enterVimMode(t, h)
622	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
623		t.Fatal("]c should be consumed")
624	}
625	if fired == nil {
626		t.Fatal("expected timer")
627	}
628	if !h.handleKeyPress(uint('c'), 0, 0) {
629		t.Fatal("]cc should complete")
630	}
631	if stops == 0 {
632		t.Fatal("next key must Stop ambiguity timer")
633	}
634	if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
635		t.Fatalf("actions = %#v, want bracket-cc", *actions)
636	}
637	// Stale timer must no-op.
638	fired()
639	if len(*actions) != 1 {
640		t.Fatalf("stale timer mutated actions: %#v", *actions)
641	}
642}
643
644func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
645	ctx := context.Background()
646	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
647		"bracket-c":    {Keys: []string{"]c"}},
648		"bracket-cc":   {Keys: []string{"]cc"}},
649		"yank-section": {Keys: []string{"yah"}},
650	})
651	h := NewKeyboardHandler(ctx, ws, newTestSession())
652	actions := captureSequenceActions(h)
653	pending := capturePending(h)
654
655	var fired func()
656	h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
657		fired = fn
658		return stubSequenceTimer{}
659	}
660	var queued []func()
661	h.SetSequenceMainThreadScheduler(func(fn func()) {
662		queued = append(queued, fn)
663	})
664
665	enterVimMode(t, h)
666	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
667		t.Fatal("]c should be consumed")
668	}
669	fired() // enqueue stale resolve
670	if len(queued) != 1 {
671		t.Fatalf("queued = %d, want 1", len(queued))
672	}
673	stale := queued[0]
674	queued = nil
675
676	// Interleave: feed/reset/reload before stale closure runs.
677	h.ResetPendingSequence()
678	if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
679		t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
680	}
681	stale()
682	if len(*actions) != 0 {
683		t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
684	}
685
686	// Reload path also invalidates queued resolve.
687	fired = nil
688	queued = nil
689	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
690		t.Fatal("]c again")
691	}
692	fired()
693	stale = queued[0]
694	h.ReloadShortcuts(ctx, ws, newTestSession())
695	stale()
696	if len(*actions) != 0 {
697		t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
698	}
699}
700
701func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
702	ctx := context.Background()
703	wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
704		"action-a":        {Keys: []string{"yah"}},
705		"vim-scroll-down": {Keys: []string{"j"}},
706	})
707	h := NewKeyboardHandler(ctx, wsA, newTestSession())
708	actions := captureSequenceActions(h)
709	enterVimMode(t, h)
710
711	if !h.handleKeyPress(uint('y'), 0, 0) {
712		t.Fatal("y should pending under trie A")
713	}
714	if h.PendingSequence() != "y" {
715		t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
716	}
717
718	wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
719		"action-b":        {Keys: []string{"gO"}},
720		"vim-scroll-down": {Keys: []string{"j"}},
721	})
722	h.ReloadShortcuts(ctx, wsB, newTestSession())
723	if h.PendingSequence() != "" {
724		t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
725	}
726
727	// Old A sequence must not complete; fall through / invalid.
728	if h.feedVimModeSequence(uint('a'), 0) {
729		t.Fatal("stale A continuation must not be consumed after reload")
730	}
731	if len(*actions) != 0 {
732		t.Fatalf("no A action after reload, got %#v", *actions)
733	}
734
735	// New B sequence works.
736	if !h.handleKeyPress(uint('g'), 0, 0) {
737		t.Fatal("g should pending under trie B")
738	}
739	if h.PendingSequence() != "g" {
740		t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
741	}
742	if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
743		t.Fatal("gO should complete under trie B")
744	}
745	if len(*actions) != 1 || (*actions)[0].action != "action-b" {
746		t.Fatalf("actions = %#v, want action-b", *actions)
747	}
748}

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