main 5041353

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			"hint-follow":     {Keys: []string{"f"}},
165			"hint-follow-new": {Keys: []string{"F"}},
166			"visual":          {Keys: []string{"v"}},
167			"confirm":         {Keys: []string{"enter"}},
168			"cancel":          {Keys: []string{"escape"}},
169		},
170	}
171
172	trie, owned := buildVimModeTrie(cfg)
173	if trie == nil {
174		t.Fatal("buildVimModeTrie returned nil trie")
175	}
176
177	wantOwned := map[string]bool{
178		"]]":    true,
179		"gi":    true,
180		"yah":   true,
181		"gO":    true,
182		"<C-d>": true,
183		"f":     true,
184		"F":     true,
185		"v":     true,
186	}
187	for key, want := range wantOwned {
188		if owned[key] != want {
189			t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
190		}
191	}
192	for _, legacy := range []string{"j", "enter", "escape"} {
193		if owned[legacy] {
194			t.Fatalf("legacy binding %q should not be owned", legacy)
195		}
196	}
197
198	seq, err := vimkeys.ParseBinding("]]")
199	if err != nil {
200		t.Fatalf("ParseBinding(]]) error = %v", err)
201	}
202	node, ok := trie.Walk(seq)
203	if !ok || !node.Exact || node.Action != "heading-next" {
204		t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
205	}
206
207	seq, err = vimkeys.ParseBinding("gi")
208	if err != nil {
209		t.Fatalf("ParseBinding(gi) error = %v", err)
210	}
211	node, ok = trie.Walk(seq)
212	if !ok || !node.Exact || node.Action != "focus-input" {
213		t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok)
214	}
215}
216
217func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
218	// Same sequence under two raw aliases; first sorted action wins Insert.
219	// Losing raw key must not be marked owned (insert failed).
220	cfg := &entity.VimModeConfig{
221		Actions: map[string]entity.ActionBinding{
222			"zzz-later":       {Keys: []string{"<c-d>"}},
223			"aaa-first":       {Keys: []string{"<C-d>"}},
224			"vim-scroll-down": {Keys: []string{"j"}},
225		},
226	}
227
228	trie, owned := buildVimModeTrie(cfg)
229	if !owned["<C-d>"] {
230		t.Fatal("winning raw key <C-d> should be owned after successful Insert")
231	}
232	if owned["<c-d>"] {
233		t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
234	}
235
236	seq, err := vimkeys.ParseBinding("<C-d>")
237	if err != nil {
238		t.Fatalf("ParseBinding(<C-d>) error = %v", err)
239	}
240	node, ok := trie.Walk(seq)
241	if !ok || !node.Exact || node.Action != "aaa-first" {
242		t.Fatalf("Walk(<C-d>) = (%#v, %v), want exact aaa-first", node, ok)
243	}
244}
245
246func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
247	t.Helper()
248
249	workspace := &entity.WorkspaceConfig{
250		VimMode: entity.VimModeConfig{
251			ActivationShortcut: "ctrl+y",
252			Actions: map[string]entity.ActionBinding{
253				"vim-scroll-down":      {Keys: []string{"j"}},
254				"vim-scroll-down-fast": {Keys: []string{"shift+j"}},
255				"confirm":              {Keys: []string{"enter"}},
256				"cancel":               {Keys: []string{"escape"}},
257				"heading-next":         {Keys: []string{"]]"}},
258				"yank-section":         {Keys: []string{"yah"}},
259				"outline":              {Keys: []string{"gO"}},
260				"half-page-down":       {Keys: []string{"<C-d>"}},
261			},
262		},
263	}
264
265	set := NewShortcutSet(context.Background(), workspace, nil)
266	if set.VimModeSequences() == nil {
267		t.Fatal("VimModeSequences() is nil")
268	}
269	return set
270}
271
272func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
273	t.Helper()
274
275	jBinding, jOK := ParseKeyString("j")
276	if !jOK {
277		t.Fatal("ParseKeyString(j) failed")
278	}
279	if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
280		t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
281	}
282
283	enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
284	if action, found := set.VimMode[enterBinding]; !found || action != ActionVimConfirm {
285		t.Fatalf("legacy Vim confirm missing from VimMode: found=%v action=%q", found, action)
286	}
287
288	escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
289	if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
290		t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
291	}
292}
293
294func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
295	t.Helper()
296
297	for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
298		if binding, parsed := ParseKeyString(key); parsed {
299			if action, found := set.VimMode[binding]; found {
300				t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
301			}
302		}
303	}
304}
305
306func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
307	t.Helper()
308
309	seq, err := vimkeys.ParseBinding("yah")
310	if err != nil {
311		t.Fatalf("ParseBinding(yah) error = %v", err)
312	}
313	node, walked := set.VimModeSequences().Walk(seq)
314	if !walked || !node.Exact || node.Action != "yank-section" {
315		t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
316	}
317}
318
319func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
320	set := vimModeSequencesOwnershipFixture(t)
321
322	t.Run("legacy bindings", func(t *testing.T) {
323		assertLegacyVimModeBindings(t, set)
324	})
325	t.Run("owned bindings absent from legacy table", func(t *testing.T) {
326		assertOwnedBindingsAbsentFromVimMode(t, set)
327	})
328	t.Run("trie resolves owned sequence", func(t *testing.T) {
329		assertVimModeTrieYankSection(t, set)
330	})
331}
332
333func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
334	ctx := context.Background()
335	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
336		"heading-next":    {Keys: []string{"]]"}},
337		"vim-scroll-down": {Keys: []string{"j"}},
338		"cancel":          {Keys: []string{"escape"}},
339	})
340	h := NewKeyboardHandler(ctx, ws, newTestSession())
341	pending := capturePending(h)
342	actions := captureSequenceActions(h)
343	enterVimMode(t, h)
344
345	if !h.handleKeyPress(uint(']'), 0, 0) {
346		t.Fatal("] prefix should be consumed")
347	}
348	if h.PendingSequence() != "]" {
349		t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
350	}
351	if len(*pending) != 1 || (*pending)[0] != "]" {
352		t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
353	}
354
355	if !h.handleKeyPress(uint(']'), 0, 0) {
356		t.Fatal("]] should be consumed")
357	}
358	if h.PendingSequence() != "" {
359		t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
360	}
361	if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
362		t.Fatalf("actions = %#v, want heading-next count 0", *actions)
363	}
364	if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
365		t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
366	}
367}
368
369func TestFeedVimModeSequence_ConfiguredConfirmActivatesAndExits(t *testing.T) {
370	ctx := context.Background()
371	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
372		"confirm": {Keys: []string{"zz"}},
373	})
374	h := NewKeyboardHandler(ctx, ws, newTestSession())
375	actions := captureSequenceActions(h)
376	enterVimMode(t, h)
377
378	if !h.handleKeyPress(uint('z'), 0, 0) {
379		t.Fatal("configured confirm prefix should be consumed")
380	}
381	if !h.handleKeyPress(uint('z'), 0, 0) {
382		t.Fatal("configured confirm completion should be consumed")
383	}
384	if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 {
385		t.Fatalf("actions = %#v, want confirm count 0", *actions)
386	}
387	if h.Mode() != ModeNormal {
388		t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode())
389	}
390}
391
392func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
393	ctx := context.Background()
394	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
395		"yank-section":    {Keys: []string{"yah"}},
396		"vim-scroll-down": {Keys: []string{"j"}},
397	})
398	h := NewKeyboardHandler(ctx, ws, newTestSession())
399	pending := capturePending(h)
400	actions := captureSequenceActions(h)
401	enterVimMode(t, h)
402
403	for _, step := range []struct {
404		key  uint
405		want string
406	}{
407		{uint('y'), "y"},
408		{uint('a'), "ya"},
409		{uint('h'), ""},
410	} {
411		if !h.handleKeyPress(step.key, 0, 0) {
412			t.Fatalf("key %c should be consumed", step.key)
413		}
414		if got := h.PendingSequence(); got != step.want {
415			t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
416		}
417	}
418	if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
419		t.Fatalf("actions = %#v, want yank-section", *actions)
420	}
421	if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
422		t.Fatalf("pending = %#v, want y then ya", *pending)
423	}
424}
425
426func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
427	ctx := context.Background()
428	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
429		"heading-next":    {Keys: []string{"]]"}},
430		"vim-scroll-down": {Keys: []string{"j"}},
431	})
432	h := NewKeyboardHandler(ctx, ws, newTestSession())
433	var scrollCalls int
434	h.SetOnAction(func(_ context.Context, action Action) error {
435		if action == ActionVimScrollDown {
436			scrollCalls++
437		}
438		return nil
439	})
440	enterVimMode(t, h)
441
442	// Unknown sequence key falls through; legacy j still works.
443	if !h.handleKeyPress(uint('j'), 0, 0) {
444		t.Fatal("legacy j should be handled")
445	}
446	if scrollCalls != 1 {
447		t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
448	}
449	if h.PendingSequence() != "" {
450		t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
451	}
452}
453
454func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
455	ctx := context.Background()
456	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
457		"heading-next": {Keys: []string{"]]"}},
458	})
459	h := NewKeyboardHandler(ctx, ws, newTestSession())
460	pending := capturePending(h)
461
462	if h.feedVimModeSequence(uint(']'), 0) {
463		t.Fatal("feed outside vim mode should return false")
464	}
465	if len(*pending) != 0 {
466		t.Fatalf("pending = %#v, want none", *pending)
467	}
468}
469
470func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
471	ctx := context.Background()
472	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
473		"yank-section": {Keys: []string{"yah"}},
474		"cancel":       {Keys: []string{"escape"}},
475	})
476	h := NewKeyboardHandler(ctx, ws, newTestSession())
477	pending := capturePending(h)
478	enterVimMode(t, h)
479
480	if !h.handleKeyPress(uint('y'), 0, 0) {
481		t.Fatal("y should be consumed")
482	}
483	before := len(*pending)
484
485	if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
486		t.Fatal("escape should exit")
487	}
488	if h.Mode() != ModeNormal {
489		t.Fatalf("mode = %v, want ModeNormal", h.Mode())
490	}
491	if h.PendingSequence() != "" {
492		t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
493	}
494	if len(*pending) != before {
495		t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
496	}
497}
498
499func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
500	ctx := context.Background()
501	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
502		"heading-next": {Keys: []string{"]]"}},
503	})
504	h := NewKeyboardHandler(ctx, ws, newTestSession())
505	actions := captureSequenceActions(h)
506	enterVimMode(t, h)
507
508	if !h.handleKeyPress(uint('2'), 0, 0) {
509		t.Fatal("count digit should be consumed")
510	}
511	if h.PendingSequence() != "2" {
512		t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
513	}
514	if !h.handleKeyPress(uint(']'), 0, 0) {
515		t.Fatal("first ] after count should be consumed")
516	}
517	if !h.handleKeyPress(uint(']'), 0, 0) {
518		t.Fatal("2]] should complete")
519	}
520	if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
521		t.Fatalf("actions = %#v, want heading-next count 2", *actions)
522	}
523}
524
525func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
526	ctx := context.Background()
527	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
528		"bracket-c":  {Keys: []string{"]c"}},
529		"bracket-cc": {Keys: []string{"]cc"}},
530	})
531	h := NewKeyboardHandler(ctx, ws, newTestSession())
532	actions := captureSequenceActions(h)
533
534	var fired func()
535	var stops int
536	h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
537		if d != 500*time.Millisecond {
538			t.Fatalf("ambiguity timeout = %v, want 500ms", d)
539		}
540		fired = fn
541		return stubSequenceTimer{onStop: func() { stops++ }}
542	}
543	var queued []func()
544	h.SetSequenceMainThreadScheduler(func(fn func()) {
545		queued = append(queued, fn)
546	})
547
548	enterVimMode(t, h)
549	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
550		t.Fatal("]c prefix should be consumed")
551	}
552	if !h.seq.matcher.Ambiguous() {
553		t.Fatal("]c should be ambiguous against ]cc")
554	}
555	if fired == nil {
556		t.Fatal("expected ambiguity timer arm")
557	}
558
559	fired() // timer expiry queues resolve on scheduler
560	if len(queued) != 1 {
561		t.Fatalf("queued resolves = %d, want 1", len(queued))
562	}
563	queued[0]()
564	if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
565		t.Fatalf("actions = %#v, want bracket-c", *actions)
566	}
567	if h.PendingSequence() != "" {
568		t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
569	}
570	_ = stops
571}
572
573func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
574	ctx := context.Background()
575	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
576		"bracket-c":  {Keys: []string{"]c"}},
577		"bracket-cc": {Keys: []string{"]cc"}},
578	})
579	ws.VimMode.SequenceTimeoutMilliseconds = 0
580	h := NewKeyboardHandler(ctx, ws, newTestSession())
581	actions := captureSequenceActions(h)
582
583	var armed time.Duration = -1
584	h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
585		armed = d
586		fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
587		return stubSequenceTimer{}
588	}
589	var queued []func()
590	h.SetSequenceMainThreadScheduler(func(fn func()) {
591		queued = append(queued, fn)
592	})
593
594	enterVimMode(t, h)
595	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
596		t.Fatal("]c should be consumed")
597	}
598	if armed != 0 {
599		t.Fatalf("armed timeout = %v, want 0", armed)
600	}
601	if len(queued) != 1 {
602		t.Fatalf("queued = %d, want 1", len(queued))
603	}
604	queued[0]()
605	if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
606		t.Fatalf("actions = %#v, want bracket-c", *actions)
607	}
608}
609
610func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
611	ctx := context.Background()
612	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
613		"bracket-c":  {Keys: []string{"]c"}},
614		"bracket-cc": {Keys: []string{"]cc"}},
615	})
616	h := NewKeyboardHandler(ctx, ws, newTestSession())
617	actions := captureSequenceActions(h)
618
619	var fired func()
620	var stops int
621	h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
622		fired = fn
623		return stubSequenceTimer{onStop: func() { stops++ }}
624	}
625	h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
626
627	enterVimMode(t, h)
628	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
629		t.Fatal("]c should be consumed")
630	}
631	if fired == nil {
632		t.Fatal("expected timer")
633	}
634	if !h.handleKeyPress(uint('c'), 0, 0) {
635		t.Fatal("]cc should complete")
636	}
637	if stops == 0 {
638		t.Fatal("next key must Stop ambiguity timer")
639	}
640	if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
641		t.Fatalf("actions = %#v, want bracket-cc", *actions)
642	}
643	// Stale timer must no-op.
644	fired()
645	if len(*actions) != 1 {
646		t.Fatalf("stale timer mutated actions: %#v", *actions)
647	}
648}
649
650func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
651	ctx := context.Background()
652	ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
653		"bracket-c":    {Keys: []string{"]c"}},
654		"bracket-cc":   {Keys: []string{"]cc"}},
655		"yank-section": {Keys: []string{"yah"}},
656	})
657	h := NewKeyboardHandler(ctx, ws, newTestSession())
658	actions := captureSequenceActions(h)
659	pending := capturePending(h)
660
661	var fired func()
662	h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
663		fired = fn
664		return stubSequenceTimer{}
665	}
666	var queued []func()
667	h.SetSequenceMainThreadScheduler(func(fn func()) {
668		queued = append(queued, fn)
669	})
670
671	enterVimMode(t, h)
672	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
673		t.Fatal("]c should be consumed")
674	}
675	fired() // enqueue stale resolve
676	if len(queued) != 1 {
677		t.Fatalf("queued = %d, want 1", len(queued))
678	}
679	stale := queued[0]
680	queued = nil
681
682	// Interleave: feed/reset/reload before stale closure runs.
683	h.ResetPendingSequence()
684	if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
685		t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
686	}
687	stale()
688	if len(*actions) != 0 {
689		t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
690	}
691
692	// Reload path also invalidates queued resolve.
693	fired = nil
694	queued = nil
695	if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
696		t.Fatal("]c again")
697	}
698	fired()
699	stale = queued[0]
700	h.ReloadShortcuts(ctx, ws, newTestSession())
701	stale()
702	if len(*actions) != 0 {
703		t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
704	}
705}
706
707func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
708	ctx := context.Background()
709	wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
710		"action-a":        {Keys: []string{"yah"}},
711		"vim-scroll-down": {Keys: []string{"j"}},
712	})
713	h := NewKeyboardHandler(ctx, wsA, newTestSession())
714	actions := captureSequenceActions(h)
715	enterVimMode(t, h)
716
717	if !h.handleKeyPress(uint('y'), 0, 0) {
718		t.Fatal("y should pending under trie A")
719	}
720	if h.PendingSequence() != "y" {
721		t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
722	}
723
724	wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
725		"action-b":        {Keys: []string{"gO"}},
726		"vim-scroll-down": {Keys: []string{"j"}},
727	})
728	h.ReloadShortcuts(ctx, wsB, newTestSession())
729	if h.PendingSequence() != "" {
730		t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
731	}
732
733	// Old A sequence must not complete; fall through / invalid.
734	if h.feedVimModeSequence(uint('a'), 0) {
735		t.Fatal("stale A continuation must not be consumed after reload")
736	}
737	if len(*actions) != 0 {
738		t.Fatalf("no A action after reload, got %#v", *actions)
739	}
740
741	// New B sequence works.
742	if !h.handleKeyPress(uint('g'), 0, 0) {
743		t.Fatal("g should pending under trie B")
744	}
745	if h.PendingSequence() != "g" {
746		t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
747	}
748	if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
749		t.Fatal("gO should complete under trie B")
750	}
751	if len(*actions) != 1 || (*actions)[0].action != "action-b" {
752		t.Fatalf("actions = %#v, want action-b", *actions)
753	}
754}

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