keyspec_test.go RawHistoryFolder

1package keys
2
3import (
4	"testing"
5
6	"github.com/bnema/vev/internal/domain"
7	"github.com/stretchr/testify/require"
8)
9
10func TestParseKeySpecAcceptsValidSpecs(t *testing.T) {
11	cases := []struct {
12		name      string
13		spec      string
14		wantRunes []rune
15		wantArrow byte
16	}{
17		{name: "letter", spec: "alt+x", wantRunes: []rune{'x'}},
18		{name: "unicode single char", spec: "alt+ñ", wantRunes: []rune{'ñ'}},
19		{name: "space", spec: "alt+space", wantRunes: []rune{' '}},
20		{name: "left arrow", spec: "alt+left", wantArrow: 'D'},
21		{name: "right arrow", spec: "alt+right", wantArrow: 'C'},
22		{name: "up arrow", spec: "alt+up", wantArrow: 'A'},
23		{name: "down arrow", spec: "alt+down", wantArrow: 'B'},
24		{name: "digit includes layout aliases", spec: "alt+2", wantRunes: []rune{'2', 'é'}},
25		{name: "digit eight includes known aliases", spec: "alt+8", wantRunes: []rune{'8', '_', '!'}},
26	}
27
28	for _, tc := range cases {
29		t.Run(tc.name, func(t *testing.T) {
30			got, err := ParseKeySpec(tc.spec)
31			require.NoError(t, err)
32			require.Equal(t, tc.wantRunes, got.altRunes)
33			require.Equal(t, tc.wantArrow, got.altArrow)
34		})
35	}
36}
37
38func TestParseKeySpecRejectsMalformedSpecs(t *testing.T) {
39	cases := []string{
40		"",
41		"x",
42		"ctrl+x",
43		"alt+",
44		"alt+xy",
45		"alt+0",
46		"alt+10",
47		"alt+tab",
48		"alt+Space",
49		" alt+x",
50		"alt+x ",
51	}
52
53	for _, spec := range cases {
54		t.Run(spec, func(t *testing.T) {
55			_, err := ParseKeySpec(spec)
56			require.Error(t, err)
57		})
58	}
59}
60
61func TestActionNamesAreCanonical(t *testing.T) {
62	cases := []struct {
63		a    Action
64		name string
65	}{
66		{ActionOpenPalette, "open-palette"},
67		{ActionToggleFloatingPane, "toggle-floating-pane"},
68		{ActionJumpAttention, "jump-attention"},
69		{ActionFocusPaneLeft, "focus-pane-left"},
70		{ActionFocusPaneRight, "focus-pane-right"},
71		{ActionFocusPaneUp, "focus-pane-up"},
72		{ActionFocusPaneDown, "focus-pane-down"},
73		{ActionSwitchTab1, "switch-tab-1"},
74		{ActionSwitchTab2, "switch-tab-2"},
75		{ActionSwitchTab3, "switch-tab-3"},
76		{ActionSwitchTab4, "switch-tab-4"},
77		{ActionSwitchTab5, "switch-tab-5"},
78		{ActionSwitchTab6, "switch-tab-6"},
79		{ActionSwitchTab7, "switch-tab-7"},
80		{ActionSwitchTab8, "switch-tab-8"},
81		{ActionSwitchTab9, "switch-tab-9"},
82		{ActionGrowPaneWidth, "grow-pane-width"},
83		{ActionShrinkPaneWidth, "shrink-pane-width"},
84		{ActionGrowPaneHeight, "grow-pane-height"},
85		{ActionShrinkPaneHeight, "shrink-pane-height"},
86		{ActionEqualizePanes, "equalize-panes"},
87		{ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"},
88		{ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"},
89	}
90
91	for _, tc := range cases {
92		t.Run(tc.name, func(t *testing.T) {
93			require.Equal(t, tc.name, tc.a.Name())
94			got, ok := ActionByName(tc.name)
95			require.True(t, ok)
96			require.Equal(t, tc.a, got)
97		})
98	}
99
100	require.Equal(t, "unknown", Action(99).Name())
101	_, ok := ActionByName("unknown-action")
102	require.False(t, ok)
103}
104
105func TestBuildBindingsAppliesOverrides(t *testing.T) {
106	bindings, warnings := BuildBindings(map[string]string{
107		"open-palette":         "alt+o",
108		"toggle-floating-pane": "alt+g",
109		"focus-pane-left":      "alt+left",
110		"switch-tab-2":         "alt+2",
111	})
112	require.Empty(t, warnings)
113
114	assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
115	assertAltRuneUnbound(t, bindings, ' ')
116	assertAltRuneAction(t, bindings, 'g', ActionToggleFloatingPane)
117	assertAltRuneUnbound(t, bindings, 'f')
118	assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft)
119	assertAltRuneUnbound(t, bindings, 'h')
120	assertAltRuneAction(t, bindings, '2', ActionSwitchTab2)
121	assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)
122}
123
124func TestResizeActionsBindWithoutDefaults(t *testing.T) {
125	tests := []struct {
126		name   string
127		action Action
128		key    rune
129	}{
130		{"grow-pane-width", ActionGrowPaneWidth, 'w'},
131		{"shrink-pane-width", ActionShrinkPaneWidth, 's'},
132		{"grow-pane-height", ActionGrowPaneHeight, 'g'},
133		{"shrink-pane-height", ActionShrinkPaneHeight, 'r'},
134		{"equalize-panes", ActionEqualizePanes, 'e'},
135	}
136
137	defaults := DefaultBindings()
138	for _, tt := range tests {
139		t.Run(tt.name, func(t *testing.T) {
140			for _, bound := range defaults.altRunes {
141				require.NotEqual(t, tt.action, bound)
142			}
143			for _, bound := range defaults.altArrows {
144				require.NotEqual(t, tt.action, bound)
145			}
146
147			bindings, warnings := BuildBindings(map[string]string{tt.name: "alt+" + string(tt.key)})
148			require.Empty(t, warnings)
149			assertAltRuneAction(t, bindings, tt.key, tt.action)
150		})
151	}
152}
153
154func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {
155	bindings, warnings := BuildBindings(map[string]string{
156		"missing":        "alt+m",
157		"open-palette":   "ctrl+o",
158		"jump-attention": "alt+j",
159	})
160
161	require.Len(t, warnings, 3)
162	warningText := warnings[0].Msg + "\n" + warnings[1].Msg + "\n" + warnings[2].Msg
163	require.Contains(t, warningText, "unknown action")
164	require.Contains(t, warningText, "invalid key")
165	require.Contains(t, warningText, "duplicate key")
166	assertAltRuneAction(t, bindings, ' ', ActionOpenPalette)
167	assertAltRuneAction(t, bindings, 'a', ActionJumpAttention)
168	assertAltRuneAction(t, bindings, 'j', ActionFocusPaneDown)
169}
170
171func TestBuildBindingsAllowsSameActionToKeepDefaultKey(t *testing.T) {
172	bindings, warnings := BuildBindings(map[string]string{
173		"focus-pane-left": "alt+h",
174	})
175
176	require.Empty(t, warnings)
177	assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft)
178}
179
180func TestBuildBindingEntriesDuplicateKeyConflictsUseFileOrder(t *testing.T) {
181	tests := []struct {
182		name              string
183		overrides         []domain.ConfigEntry
184		wantAltP          Action
185		wantOpenDefault   bool
186		wantFocusDefault  bool
187		wantWarningAction string
188	}{
189		{
190			name: "open palette first wins",
191			overrides: []domain.ConfigEntry{
192				{Key: "open-palette", Value: "alt+p"},
193				{Key: "focus-pane-left", Value: "alt+p"},
194			},
195			wantAltP:          ActionOpenPalette,
196			wantFocusDefault:  true,
197			wantWarningAction: "focus-pane-left",
198		},
199		{
200			name: "focus pane left first wins",
201			overrides: []domain.ConfigEntry{
202				{Key: "focus-pane-left", Value: "alt+p"},
203				{Key: "open-palette", Value: "alt+p"},
204			},
205			wantAltP:          ActionFocusPaneLeft,
206			wantOpenDefault:   true,
207			wantWarningAction: "open-palette",
208		},
209	}
210
211	for _, tt := range tests {
212		t.Run(tt.name, func(t *testing.T) {
213			bindings, warnings := BuildBindingEntries(tt.overrides)
214
215			require.Len(t, warnings, 1)
216			require.Contains(t, warnings[0].Msg, tt.wantWarningAction)
217			assertAltRuneAction(t, bindings, 'p', tt.wantAltP)
218			if tt.wantOpenDefault {
219				assertAltRuneAction(t, bindings, ' ', ActionOpenPalette)
220			} else {
221				assertAltRuneUnbound(t, bindings, ' ')
222			}
223			if tt.wantFocusDefault {
224				assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft)
225			} else {
226				assertAltRuneUnbound(t, bindings, 'h')
227			}
228		})
229	}
230}
231
232func TestBuildBindingsFreesDefaultsForOverriddenActionsBeforeConflictChecks(t *testing.T) {
233	bindings, warnings := BuildBindings(map[string]string{
234		"open-palette":    "alt+o",
235		"focus-pane-down": "alt+space",
236	})
237
238	require.Empty(t, warnings)
239	assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
240	assertAltRuneAction(t, bindings, ' ', ActionFocusPaneDown)
241	assertAltRuneUnbound(t, bindings, 'j')
242}
243
244func TestBuildBindingEntriesFailedOverrideRestoreDoesNotOverwriteEarlierOverride(t *testing.T) {
245	bindings, warnings := BuildBindingEntries([]domain.ConfigEntry{
246		{Key: "open-palette", Value: "alt+a"},
247		{Key: "jump-attention", Value: "alt+a"},
248	})
249
250	require.Len(t, warnings, 1)
251	assertAltRuneAction(t, bindings, 'a', ActionOpenPalette)
252	assertAltRuneUnbound(t, bindings, ' ')
253}
254
255func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) {
256	t.Helper()
257	got, ok := bindings.actionForAltRune(key)
258	require.True(t, ok)
259	require.Equal(t, want, got)
260}
261
262func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) {
263	t.Helper()
264	_, ok := bindings.actionForAltRune(key)
265	require.False(t, ok)
266}
267
268func assertAltArrowAction(t *testing.T, bindings *Bindings, final byte, want Action) {
269	t.Helper()
270	got, ok := bindings.actionForAltArrow(final)
271	require.True(t, ok)
272	require.Equal(t, want, got)
273}

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 12

Tags 11

↑↓ moveenter openesc close