prefix_mode_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	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11)
12
13func newTmuxWorkspace() *entity.WorkspaceConfig {
14	ws := newTestWorkspace()
15	ws.Keymap = entity.KeymapTmux
16	ws.VimMode.ActivationShortcut = "ctrl+y"
17	ws.PrefixMode = entity.PrefixModeConfig{
18		ActivationShortcut: "ctrl+space",
19		Actions: map[string]entity.ActionBinding{
20			"split-right":       {Keys: []string{"%"}},
21			"split-down":        {Keys: []string{"\""}},
22			"new-tab":           {Keys: []string{"c"}},
23			"next-tab":          {Keys: []string{"n"}},
24			"enter-resize-mode": {Keys: []string{"r"}},
25			"cancel":            {Keys: []string{"escape"}},
26		},
27	}
28	return ws
29}
30
31func recordActions(h *KeyboardHandler) *[]Action {
32	var got []Action
33	h.SetOnAction(func(_ context.Context, action Action) error {
34		got = append(got, action)
35		return nil
36	})
37	return &got
38}
39
40func TestShortcutSet_TmuxKeymapReplacesModeActivations(t *testing.T) {
41	set := NewShortcutSet(context.Background(), newTmuxWorkspace(), newTestSession())
42
43	prefix, _ := ParseKeyString("ctrl+space")
44	assert.Equal(t, ActionEnterPrefixMode, set.Global[prefix])
45	for _, key := range []string{"ctrl+t", "ctrl+p", "ctrl+alt+r", "ctrl+s"} {
46		binding, ok := ParseKeyString(key)
47		require.True(t, ok)
48		_, bound := set.Global[binding]
49		assert.Falsef(t, bound, "%s must not activate a mode in the tmux keymap", key)
50	}
51	vim, _ := ParseKeyString("ctrl+y")
52	assert.Equal(t, ActionEnterVimMode, set.Global[vim], "vim mode keeps its own activation")
53}
54
55func TestShortcutSet_ZellijKeymapIgnoresPrefix(t *testing.T) {
56	ws := newTmuxWorkspace()
57	ws.Keymap = entity.KeymapZellij
58	set := NewShortcutSet(context.Background(), ws, newTestSession())
59
60	prefix, _ := ParseKeyString("ctrl+space")
61	_, bound := set.Global[prefix]
62	assert.False(t, bound)
63	tab, _ := ParseKeyString("ctrl+t")
64	assert.Equal(t, ActionEnterTabMode, set.Global[tab])
65}
66
67func TestHandleKeyPress_PrefixDispatchesOneActionThenExits(t *testing.T) {
68	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
69	got := recordActions(h)
70
71	require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
72	require.Equal(t, ModePrefix, h.Mode())
73
74	// '%' is typed with Shift on most layouts; the binding omits Shift.
75	require.True(t, h.handleKeyPress(uint(gdk.KEY_percent), 0, gdk.ShiftMaskValue))
76	assert.Equal(t, []Action{ActionSplitRight}, *got)
77	assert.Equal(t, ModeNormal, h.Mode(), "prefix is one-shot")
78
79	// A plain key after the prefix exited must not dispatch again.
80	assert.False(t, h.handleKeyPress(uint('n'), 0, 0))
81	assert.Len(t, *got, 1)
82}
83
84func TestHandleKeyPress_PrefixUnboundKeyCancels(t *testing.T) {
85	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
86	got := recordActions(h)
87
88	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
89	assert.True(t, h.handleKeyPress(uint('z'), 0, 0), "unbound key is consumed")
90	assert.Equal(t, ModeNormal, h.Mode())
91	assert.Empty(t, *got)
92}
93
94func TestHandleKeyPress_PrefixCanEnterPersistentMode(t *testing.T) {
95	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
96
97	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
98	h.handleKeyPress(uint('r'), 0, 0)
99	assert.Equal(t, ModeResize, h.Mode(), "prefix action may switch into another mode")
100}
101
102func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
103	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
104
105	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
106	h.handleKeyRelease(uint(gdk.KEY_space))
107	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
108	assert.Equal(t, ModeNormal, h.Mode())
109}
110
111func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
112	ws := newTestWorkspace()
113	ws.Shortcuts.Actions = map[string]entity.ActionBinding{
114		"reload": {Keys: []string{}},               // disabled by user
115		"quit":   {Keys: []string{"ctrl+shift+q"}}, // rebound by user
116	}
117	set := NewShortcutSet(context.Background(), ws, newTestSession())
118
119	lookup := func(key string) (Action, bool) {
120		binding, ok := ParseKeyString(key)
121		require.True(t, ok, key)
122		action, found := set.Global[binding]
123		return action, found
124	}
125
126	action, ok := lookup("ctrl+l")
127	assert.True(t, ok, "missing builtin action falls back to its default key")
128	assert.Equal(t, ActionOpenOmnibox, action)
129
130	_, ok = lookup("ctrl+r")
131	assert.False(t, ok, "empty keys disable a builtin shortcut")
132
133	_, ok = lookup("ctrl+q")
134	assert.False(t, ok, "rebound builtin drops its default key")
135	action, _ = lookup("ctrl+shift+q")
136	assert.Equal(t, ActionQuit, action)
137}
138
139func TestParseKeyString_PrintableSymbols(t *testing.T) {
140	for _, key := range []string{"%", "\"", "&", ",", "!"} {
141		binding, ok := ParseKeyString(key)
142		require.Truef(t, ok, "%q should parse", key)
143		assert.Equal(t, uint(key[0]), binding.Keyval)
144		assert.Equal(t, ModNone, binding.Modifiers)
145	}
146}
147
148type keyEvent struct {
149	keyval uint
150	state  gdk.ModifierType
151}
152
153// TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers
154// them: modifier and dead-key presses arrive before the character.
155func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) {
156	shift := gdk.ShiftMaskValue
157	tests := []struct {
158		name       string
159		events     []keyEvent
160		wantAction []Action
161		wantMode   Mode
162		lastResult bool
163	}{
164		{
165			name:       "shift then percent splits right",
166			events:     []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}},
167			wantAction: []Action{ActionSplitRight},
168			wantMode:   ModeNormal,
169			lastResult: true,
170		},
171		{
172			name:       "shift then quote splits down",
173			events:     []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}},
174			wantAction: []Action{ActionSplitDown},
175			wantMode:   ModeNormal,
176			lastResult: true,
177		},
178		{
179			name:       "dead diaeresis splits down on international layouts",
180			events:     []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}},
181			wantAction: []Action{ActionSplitDown},
182			wantMode:   ModeNormal,
183			lastResult: true,
184		},
185		{
186			name:       "altgr alone keeps the prefix waiting",
187			events:     []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}},
188			wantMode:   ModePrefix,
189			lastResult: true,
190		},
191		{
192			name:       "unbound modified key cancels and passes through",
193			events:     []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}},
194			wantMode:   ModeNormal,
195			lastResult: false,
196		},
197	}
198
199	for _, tt := range tests {
200		t.Run(tt.name, func(t *testing.T) {
201			h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
202			got := recordActions(h)
203			require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
204			h.handleKeyRelease(uint(gdk.KEY_space))
205
206			var result bool
207			for _, ev := range tt.events {
208				result = h.handleKeyPress(ev.keyval, 0, ev.state)
209			}
210			assert.Equal(t, tt.lastResult, result)
211			assert.Equal(t, tt.wantMode, h.Mode())
212			assert.Equal(t, tt.wantAction, *got)
213		})
214	}
215}
216
217func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) {
218	ws := newTestWorkspace()
219	ws.Shortcuts.Actions = map[string]entity.ActionBinding{
220		"toggle-floating-pane": {Keys: []string{"ctrl+l"}},
221		"open-omnibox":         {Keys: []string{"ctrl+l"}},
222	}
223	for range 20 {
224		set := NewShortcutSet(context.Background(), ws, newTestSession())
225		binding, _ := ParseKeyString("ctrl+l")
226		require.Equal(t, ActionOpenOmnibox, set.Global[binding])
227	}
228}

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