main 5041353

shortcuts_test.go RawHistoryFolder

1package input
2
3import (
4	"context"
5	"testing"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/bnema/puregotk/v4/gdk"
9)
10
11func TestParseKeyString_SingleKeys(t *testing.T) {
12	tests := []struct {
13		name   string
14		input  string
15		want   KeyBinding
16		wantOk bool
17	}{
18		{
19			name:   "escape",
20			input:  "escape",
21			want:   KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone},
22			wantOk: true,
23		},
24		{
25			name:   "esc alias",
26			input:  "esc",
27			want:   KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone},
28			wantOk: true,
29		},
30		{
31			name:   "return",
32			input:  "return",
33			want:   KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone},
34			wantOk: true,
35		},
36		{
37			name:   "enter alias",
38			input:  "enter",
39			want:   KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone},
40			wantOk: true,
41		},
42		{
43			name:   "tab",
44			input:  "tab",
45			want:   KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModNone},
46			wantOk: true,
47		},
48		{
49			name:   "space",
50			input:  "space",
51			want:   KeyBinding{Keyval: uint(gdk.KEY_space), Modifiers: ModNone},
52			wantOk: true,
53		},
54		{
55			name:   "plus symbol",
56			input:  "+",
57			want:   KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone},
58			wantOk: true,
59		},
60		{
61			name:   "f5",
62			input:  "f5",
63			want:   KeyBinding{Keyval: uint(gdk.KEY_F5), Modifiers: ModNone},
64			wantOk: true,
65		},
66		{
67			name:   "f12",
68			input:  "f12",
69			want:   KeyBinding{Keyval: uint(gdk.KEY_F12), Modifiers: ModNone},
70			wantOk: true,
71		},
72		{
73			name:   "single letter",
74			input:  "t",
75			want:   KeyBinding{Keyval: uint('t'), Modifiers: ModNone},
76			wantOk: true,
77		},
78		{
79			name:   "single number",
80			input:  "0",
81			want:   KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModNone},
82			wantOk: true,
83		},
84		{
85			name:   "arrow left",
86			input:  "left",
87			want:   KeyBinding{Keyval: uint(gdk.KEY_Left), Modifiers: ModNone},
88			wantOk: true,
89		},
90		{
91			name:   "arrow right",
92			input:  "right",
93			want:   KeyBinding{Keyval: uint(gdk.KEY_Right), Modifiers: ModNone},
94			wantOk: true,
95		},
96	}
97
98	for _, tt := range tests {
99		t.Run(tt.name, func(t *testing.T) {
100			got, ok := ParseKeyString(tt.input)
101			if ok != tt.wantOk {
102				t.Errorf("ParseKeyString(%q) ok = %v, want %v", tt.input, ok, tt.wantOk)
103				return
104			}
105			if !tt.wantOk {
106				return
107			}
108			if got.Keyval != tt.want.Keyval {
109				t.Errorf("ParseKeyString(%q) keyval = %d, want %d", tt.input, got.Keyval, tt.want.Keyval)
110			}
111			if got.Modifiers != tt.want.Modifiers {
112				t.Errorf("ParseKeyString(%q) modifiers = %d, want %d", tt.input, got.Modifiers, tt.want.Modifiers)
113			}
114		})
115	}
116}
117
118func TestParseKeyString_WithModifiers(t *testing.T) {
119	tests := []struct {
120		name   string
121		input  string
122		want   KeyBinding
123		wantOk bool
124	}{
125		{
126			name:   "ctrl+t",
127			input:  "ctrl+t",
128			want:   KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl},
129			wantOk: true,
130		},
131		{
132			name:   "control+t",
133			input:  "control+t",
134			want:   KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl},
135			wantOk: true,
136		},
137		{
138			name:   "shift+tab",
139			input:  "shift+tab",
140			want:   KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModShift},
141			wantOk: true,
142		},
143		{
144			name:   "alt+left",
145			input:  "alt+left",
146			want:   KeyBinding{Keyval: uint(gdk.KEY_Left), Modifiers: ModAlt},
147			wantOk: true,
148		},
149		{
150			name:   "ctrl+shift+t",
151			input:  "ctrl+shift+t",
152			want:   KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl | ModShift},
153			wantOk: true,
154		},
155		{
156			name:   "ctrl+r",
157			input:  "ctrl+r",
158			want:   KeyBinding{Keyval: uint('r'), Modifiers: ModCtrl},
159			wantOk: true,
160		},
161		{
162			name:   "ctrl++",
163			input:  "ctrl++",
164			want:   KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModCtrl},
165			wantOk: true,
166		},
167	}
168
169	for _, tt := range tests {
170		t.Run(tt.name, func(t *testing.T) {
171			got, ok := ParseKeyString(tt.input)
172			if ok != tt.wantOk {
173				t.Errorf("ParseKeyString(%q) ok = %v, want %v", tt.input, ok, tt.wantOk)
174				return
175			}
176			if !tt.wantOk {
177				return
178			}
179			if got.Keyval != tt.want.Keyval {
180				t.Errorf("ParseKeyString(%q) keyval = %d, want %d", tt.input, got.Keyval, tt.want.Keyval)
181			}
182			if got.Modifiers != tt.want.Modifiers {
183				t.Errorf("ParseKeyString(%q) modifiers = %d, want %d", tt.input, got.Modifiers, tt.want.Modifiers)
184			}
185		})
186	}
187}
188
189func TestParseKeyString_Invalid(t *testing.T) {
190	tests := []string{
191		"",
192		"ctrl+",
193		"unknownkey",
194		"ctrl+unknownkey",
195	}
196
197	for _, input := range tests {
198		t.Run(input, func(t *testing.T) {
199			_, ok := ParseKeyString(input)
200			if ok {
201				t.Errorf("ParseKeyString(%q) should have failed", input)
202			}
203		})
204	}
205}
206
207func TestParseKeyString_CaseInsensitive(t *testing.T) {
208	tests := []struct {
209		input1 string
210		input2 string
211	}{
212		// Modifiers and named keys are case-insensitive.
213		{"ctrl+t", "CTRL+t"},
214		{"Escape", "escape"},
215		{"Tab", "TAB"},
216		// If shift is explicitly present, the key letter case shouldn't matter.
217		{"Ctrl+Shift+T", "ctrl+shift+t"},
218	}
219
220	for _, tt := range tests {
221		t.Run(tt.input1+" vs "+tt.input2, func(t *testing.T) {
222			got1, ok1 := ParseKeyString(tt.input1)
223			got2, ok2 := ParseKeyString(tt.input2)
224
225			if ok1 != ok2 {
226				t.Errorf("ParseKeyString case sensitivity: %q=%v, %q=%v", tt.input1, ok1, tt.input2, ok2)
227				return
228			}
229
230			if got1 != got2 {
231				t.Errorf("ParseKeyString case sensitivity: %q=%v, %q=%v", tt.input1, got1, tt.input2, got2)
232			}
233		})
234	}
235}
236
237func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) {
238	got1, ok1 := ParseKeyString("M")
239	got2, ok2 := ParseKeyString("shift+m")
240	if !ok1 || !ok2 {
241		t.Fatalf("ParseKeyString should succeed for M and shift+m")
242	}
243	if got1 != got2 {
244		t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2)
245	}
246}
247
248func TestShortcutSet_Lookup(t *testing.T) {
249	set := &ShortcutSet{
250		Global: ShortcutTable{
251			{uint(gdk.KEY_q), ModCtrl}: ActionQuit,
252			{uint(gdk.KEY_l), ModCtrl}: ActionOpenOmnibox,
253		},
254		TabMode: ShortcutTable{
255			{uint('n'), ModNone}:            ActionNewTab,
256			{uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
257		},
258		PaneMode: ShortcutTable{
259			{uint('x'), ModNone}:            ActionClosePane,
260			{uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
261		},
262	}
263
264	tests := []struct {
265		name    string
266		binding KeyBinding
267		mode    Mode
268		want    Action
269		wantOk  bool
270	}{
271		{
272			name:    "global in normal mode",
273			binding: KeyBinding{uint(gdk.KEY_q), ModCtrl},
274			mode:    ModeNormal,
275			want:    ActionQuit,
276			wantOk:  true,
277		},
278		{
279			name:    "global in tab mode",
280			binding: KeyBinding{uint(gdk.KEY_l), ModCtrl},
281			mode:    ModeTab,
282			want:    ActionOpenOmnibox,
283			wantOk:  true,
284		},
285		{
286			name:    "tab mode action",
287			binding: KeyBinding{uint('n'), ModNone},
288			mode:    ModeTab,
289			want:    ActionNewTab,
290			wantOk:  true,
291		},
292		{
293			name:    "pane mode action",
294			binding: KeyBinding{uint('x'), ModNone},
295			mode:    ModePane,
296			want:    ActionClosePane,
297			wantOk:  true,
298		},
299		{
300			name:    "escape exits tab mode",
301			binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
302			mode:    ModeTab,
303			want:    ActionExitMode,
304			wantOk:  true,
305		},
306		{
307			name:    "escape exits pane mode",
308			binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
309			mode:    ModePane,
310			want:    ActionExitMode,
311			wantOk:  true,
312		},
313		{
314			name:    "unknown key in normal mode",
315			binding: KeyBinding{uint('z'), ModNone},
316			mode:    ModeNormal,
317			want:    "",
318			wantOk:  false,
319		},
320		{
321			name:    "tab action not in normal mode",
322			binding: KeyBinding{uint('n'), ModNone},
323			mode:    ModeNormal,
324			want:    "",
325			wantOk:  false,
326		},
327	}
328
329	for _, tt := range tests {
330		t.Run(tt.name, func(t *testing.T) {
331			got, ok := set.Lookup(tt.binding, tt.mode)
332			if ok != tt.wantOk {
333				t.Errorf("Lookup() ok = %v, want %v", ok, tt.wantOk)
334				return
335			}
336			if got != tt.want {
337				t.Errorf("Lookup() = %v, want %v", got, tt.want)
338			}
339		})
340	}
341}
342
343func TestShouldAutoExitMode(t *testing.T) {
344	exitActions := []Action{
345		ActionNewTab,
346		ActionCloseTab,
347		ActionRenameTab,
348		ActionSplitRight,
349		ActionSplitLeft,
350		ActionSplitUp,
351		ActionSplitDown,
352		ActionClosePane,
353		ActionStackPane,
354		ActionMovePaneToTab,
355		ActionMovePaneToNextTab,
356		ActionEjectPaneToWindow,
357	}
358
359	stayActions := []Action{
360		ActionNextTab,
361		ActionPreviousTab,
362		ActionFocusRight,
363		ActionFocusLeft,
364		ActionGoBack,
365		ActionZoomIn,
366		ActionOpenOmnibox,
367	}
368
369	for _, action := range exitActions {
370		if !ShouldAutoExitMode(action) {
371			t.Errorf("ShouldAutoExitMode(%s) = false, want true", action)
372		}
373	}
374
375	for _, action := range stayActions {
376		if ShouldAutoExitMode(action) {
377			t.Errorf("ShouldAutoExitMode(%s) = true, want false", action)
378		}
379	}
380}
381
382func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
383	tests := []string{"eject-pane-to-window"}
384	for _, name := range tests {
385		t.Run(name, func(t *testing.T) {
386			if got := mapConfigAction(name); got != ActionEjectPaneToWindow {
387				t.Fatalf("mapConfigAction(%s) = %s, want %s", name, got, ActionEjectPaneToWindow)
388			}
389		})
390	}
391}
392
393func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
394	action := mapConfigAction("toggle-floating-pane")
395	if action != ActionToggleFloatingPane {
396		t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
397	}
398}
399
400func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
401	tests := []struct {
402		name string
403		want Action
404	}{
405		{name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
406		{name: "toggle-favorites-sidebar", want: ActionToggleFavoritesSidebar},
407		{name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
408		{name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
409	}
410
411	for _, tt := range tests {
412		t.Run(tt.name, func(t *testing.T) {
413			if got := mapConfigAction(tt.name); got != tt.want {
414				t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
415			}
416		})
417	}
418}
419
420func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
421	action := mapConfigAction("toggle-floating-pane")
422	if action != ActionToggleFloatingPane {
423		t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
424	}
425}
426
427func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
428	actions := globalShortcutActionMap()
429	tests := []struct {
430		name string
431		want Action
432	}{
433		{name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft},
434		{name: "consume-or-expel-right", want: ActionConsumeOrExpelRight},
435		{name: "consume-or-expel-up", want: ActionConsumeOrExpelUp},
436		{name: "consume-or-expel-down", want: ActionConsumeOrExpelDown},
437	}
438
439	for _, tt := range tests {
440		t.Run(tt.name, func(t *testing.T) {
441			if got := actions[tt.name]; got != tt.want {
442				t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want)
443			}
444		})
445	}
446}
447
448func TestNewShortcutSet_VimModeActivationShortcut(t *testing.T) {
449	workspace := &entity.WorkspaceConfig{
450		TabMode:  entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
451		PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
452		VimMode:  entity.VimModeConfig{ActivationShortcut: "ctrl+y"},
453	}
454
455	set := NewShortcutSet(context.Background(), workspace, nil)
456
457	binding, ok := ParseKeyString("ctrl+y")
458	if !ok {
459		t.Fatal("failed to parse ctrl+y")
460	}
461
462	action, found := set.Global[binding]
463	if !found {
464		t.Fatal("vim mode activation shortcut not found in Global")
465	}
466	if action != ActionEnterVimMode {
467		t.Fatalf("Global shortcut action = %s, want %s", action, ActionEnterVimMode)
468	}
469}
470
471func TestShortcutSet_Lookup_VimMode(t *testing.T) {
472	set := &ShortcutSet{
473		Global: ShortcutTable{
474			{uint(gdk.KEY_q), ModCtrl}: ActionQuit,
475		},
476		VimMode: ShortcutTable{
477			{uint('h'), ModNone}:            ActionVimScrollLeft,
478			{uint('j'), ModNone}:            ActionVimScrollDown,
479			{uint('k'), ModNone}:            ActionVimScrollUp,
480			{uint('l'), ModNone}:            ActionVimScrollRight,
481			{uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
482			{uint(gdk.KEY_Return), ModNone}: ActionExitMode,
483			{uint('y'), ModCtrl}:            ActionEnterVimMode,
484		},
485	}
486
487	tests := []struct {
488		name    string
489		binding KeyBinding
490		want    Action
491		wantOk  bool
492	}{
493		{
494			name:    "scroll left",
495			binding: KeyBinding{uint('h'), ModNone},
496			want:    ActionVimScrollLeft,
497			wantOk:  true,
498		},
499		{
500			name:    "scroll down",
501			binding: KeyBinding{uint('j'), ModNone},
502			want:    ActionVimScrollDown,
503			wantOk:  true,
504		},
505		{
506			name:    "scroll up",
507			binding: KeyBinding{uint('k'), ModNone},
508			want:    ActionVimScrollUp,
509			wantOk:  true,
510		},
511		{
512			name:    "scroll right",
513			binding: KeyBinding{uint('l'), ModNone},
514			want:    ActionVimScrollRight,
515			wantOk:  true,
516		},
517		{
518			name:    "escape exits vim mode",
519			binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
520			want:    ActionExitMode,
521			wantOk:  true,
522		},
523		{
524			name:    "enter exits vim mode",
525			binding: KeyBinding{uint(gdk.KEY_Return), ModNone},
526			want:    ActionExitMode,
527			wantOk:  true,
528		},
529		{
530			name:    "activation shortcut still toggles vim mode",
531			binding: KeyBinding{uint('y'), ModCtrl},
532			want:    ActionEnterVimMode,
533			wantOk:  true,
534		},
535		{
536			name:    "global shortcut does not fire inside vim mode",
537			binding: KeyBinding{uint(gdk.KEY_q), ModCtrl},
538			want:    "",
539			wantOk:  false,
540		},
541	}
542
543	for _, tt := range tests {
544		t.Run(tt.name, func(t *testing.T) {
545			got, ok := set.Lookup(tt.binding, ModeVim)
546			if ok != tt.wantOk {
547				t.Errorf("Lookup() ok = %v, want %v", ok, tt.wantOk)
548				return
549			}
550			if got != tt.want {
551				t.Errorf("Lookup() = %v, want %v", got, tt.want)
552			}
553		})
554	}
555}
556
557func TestMapConfigAction_VimScrollActions(t *testing.T) {
558	tests := []struct {
559		name     string
560		config   string
561		expected Action
562	}{
563		{name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft},
564		{name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown},
565		{name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp},
566		{name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight},
567		{name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast},
568		{name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast},
569	}
570
571	for _, tt := range tests {
572		t.Run(tt.name, func(t *testing.T) {
573			got := mapConfigAction(tt.config)
574			if got != tt.expected {
575				t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.config, got, tt.expected)
576			}
577		})
578	}
579}

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