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", "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_ToggleSystemViews(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_ToggleSystemViewsHyphenAlias(t *testing.T) {
421	tests := []struct {
422		name string
423		want Action
424	}{
425		{name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
426		{name: "toggle-favorites-sidebar", want: ActionToggleFavoritesSidebar},
427		{name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
428		{name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
429	}
430
431	for _, tt := range tests {
432		t.Run(tt.name, func(t *testing.T) {
433			if got := mapConfigAction(tt.name); got != tt.want {
434				t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
435			}
436		})
437	}
438}
439
440func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
441	action := mapConfigAction("toggle-floating-pane")
442	if action != ActionToggleFloatingPane {
443		t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
444	}
445}
446
447func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
448	actions := globalShortcutActionMap()
449	tests := []struct {
450		name string
451		want Action
452	}{
453		{name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft},
454		{name: "consume-or-expel-right", want: ActionConsumeOrExpelRight},
455		{name: "consume-or-expel-up", want: ActionConsumeOrExpelUp},
456		{name: "consume-or-expel-down", want: ActionConsumeOrExpelDown},
457	}
458
459	for _, tt := range tests {
460		t.Run(tt.name, func(t *testing.T) {
461			if got := actions[tt.name]; got != tt.want {
462				t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want)
463			}
464		})
465	}
466}
467
468func TestNewShortcutSet_VimModeActivationShortcut(t *testing.T) {
469	workspace := &entity.WorkspaceConfig{
470		TabMode:  entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
471		PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
472		VimMode:  entity.VimModeConfig{ActivationShortcut: "ctrl+y"},
473	}
474
475	set := NewShortcutSet(context.Background(), workspace, nil)
476
477	binding, ok := ParseKeyString("ctrl+y")
478	if !ok {
479		t.Fatal("failed to parse ctrl+y")
480	}
481
482	action, found := set.Global[binding]
483	if !found {
484		t.Fatal("vim mode activation shortcut not found in Global")
485	}
486	if action != ActionEnterVimMode {
487		t.Fatalf("Global shortcut action = %s, want %s", action, ActionEnterVimMode)
488	}
489}
490
491func TestShortcutSet_Lookup_VimMode(t *testing.T) {
492	set := &ShortcutSet{
493		Global: ShortcutTable{
494			{uint(gdk.KEY_q), ModCtrl}: ActionQuit,
495		},
496		VimMode: ShortcutTable{
497			{uint('h'), ModNone}:            ActionVimScrollLeft,
498			{uint('j'), ModNone}:            ActionVimScrollDown,
499			{uint('k'), ModNone}:            ActionVimScrollUp,
500			{uint('l'), ModNone}:            ActionVimScrollRight,
501			{uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
502			{uint(gdk.KEY_Return), ModNone}: ActionExitMode,
503			{uint('y'), ModCtrl}:            ActionEnterVimMode,
504		},
505	}
506
507	tests := []struct {
508		name    string
509		binding KeyBinding
510		want    Action
511		wantOk  bool
512	}{
513		{
514			name:    "scroll left",
515			binding: KeyBinding{uint('h'), ModNone},
516			want:    ActionVimScrollLeft,
517			wantOk:  true,
518		},
519		{
520			name:    "scroll down",
521			binding: KeyBinding{uint('j'), ModNone},
522			want:    ActionVimScrollDown,
523			wantOk:  true,
524		},
525		{
526			name:    "scroll up",
527			binding: KeyBinding{uint('k'), ModNone},
528			want:    ActionVimScrollUp,
529			wantOk:  true,
530		},
531		{
532			name:    "scroll right",
533			binding: KeyBinding{uint('l'), ModNone},
534			want:    ActionVimScrollRight,
535			wantOk:  true,
536		},
537		{
538			name:    "escape exits vim mode",
539			binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
540			want:    ActionExitMode,
541			wantOk:  true,
542		},
543		{
544			name:    "enter exits vim mode",
545			binding: KeyBinding{uint(gdk.KEY_Return), ModNone},
546			want:    ActionExitMode,
547			wantOk:  true,
548		},
549		{
550			name:    "activation shortcut still toggles vim mode",
551			binding: KeyBinding{uint('y'), ModCtrl},
552			want:    ActionEnterVimMode,
553			wantOk:  true,
554		},
555		{
556			name:    "global shortcut does not fire inside vim mode",
557			binding: KeyBinding{uint(gdk.KEY_q), ModCtrl},
558			want:    "",
559			wantOk:  false,
560		},
561	}
562
563	for _, tt := range tests {
564		t.Run(tt.name, func(t *testing.T) {
565			got, ok := set.Lookup(tt.binding, ModeVim)
566			if ok != tt.wantOk {
567				t.Errorf("Lookup() ok = %v, want %v", ok, tt.wantOk)
568				return
569			}
570			if got != tt.want {
571				t.Errorf("Lookup() = %v, want %v", got, tt.want)
572			}
573		})
574	}
575}
576
577func TestMapConfigAction_VimScrollActions(t *testing.T) {
578	tests := []struct {
579		name     string
580		config   string
581		expected Action
582	}{
583		{name: "vim_scroll_left", config: "vim_scroll_left", expected: ActionVimScrollLeft},
584		{name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft},
585		{name: "vim_scroll_down", config: "vim_scroll_down", expected: ActionVimScrollDown},
586		{name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown},
587		{name: "vim_scroll_up", config: "vim_scroll_up", expected: ActionVimScrollUp},
588		{name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp},
589		{name: "vim_scroll_right", config: "vim_scroll_right", expected: ActionVimScrollRight},
590		{name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight},
591		{name: "vim_scroll_down_fast", config: "vim_scroll_down_fast", expected: ActionVimScrollDownFast},
592		{name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast},
593		{name: "vim_scroll_up_fast", config: "vim_scroll_up_fast", expected: ActionVimScrollUpFast},
594		{name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast},
595	}
596
597	for _, tt := range tests {
598		t.Run(tt.name, func(t *testing.T) {
599			got := mapConfigAction(tt.config)
600			if got != tt.expected {
601				t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.config, got, tt.expected)
602			}
603		})
604	}
605}

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