vim_mode_config_test.go RawHistoryFolder

1package config
2
3import (
4	"testing"
5
6	"github.com/spf13/viper"
7	"github.com/stretchr/testify/assert"
8	"github.com/stretchr/testify/require"
9)
10
11var legacyVimModeActions = []string{
12	"vim-scroll-left",
13	"vim-scroll-down",
14	"vim-scroll-up",
15	"vim-scroll-right",
16	"vim-scroll-down-fast",
17	"vim-scroll-up-fast",
18	"confirm",
19	"cancel",
20}
21
22var sequenceVimModeActions = map[string]string{
23	"heading-next":   "]]",
24	"heading-prev":   "[[",
25	"focus-input":    "gi",
26	"code-next":      "]c",
27	"code-prev":      "[c",
28	"table-next":     "]t",
29	"image-next":     "]i",
30	"list-next":      "]l",
31	"outline":        "gO",
32	"yank-section":   "yah",
33	"half-page-down": "<C-d>",
34	"half-page-up":   "<C-u>",
35}
36
37func TestDefaultConfig_VimModeDefaults(t *testing.T) {
38	cfg := DefaultConfig()
39
40	// VimMode must exist in workspace config.
41	vimMode := cfg.Workspace.VimMode
42	assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
43	assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
44	assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
45
46	// All required actions must exist with exact key bindings.
47	require.NotNil(t, vimMode.Actions)
48	require.Len(t, legacyVimModeActions, 8)
49	require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
50	requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})
51	requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})
52	requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})
53	requireActionBinding(t, vimMode.Actions, "vim-scroll-right", []string{"l"})
54	requireActionBinding(t, vimMode.Actions, "vim-scroll-down-fast", []string{"shift+j"})
55	requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})
56	requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
57	requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
58	for _, action := range legacyVimModeActions {
59		_, ok := vimMode.Actions[action]
60		require.True(t, ok, "missing legacy vim mode action %q", action)
61	}
62}
63
64func TestVimModeSequenceDefaults(t *testing.T) {
65	cfg := DefaultConfig()
66	vimMode := cfg.Workspace.VimMode
67
68	assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
69	require.Len(t, sequenceVimModeActions, 12)
70
71	for action, key := range sequenceVimModeActions {
72		requireActionBinding(t, vimMode.Actions, action, []string{key})
73	}
74}
75
76func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) {
77	mgr := &Manager{viper: viper.New()}
78	mgr.setDefaults()
79
80	cfg, err := mgr.unmarshalConfig()
81	require.NoError(t, err)
82
83	vimMode := cfg.Workspace.VimMode
84	assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
85	assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
86	assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
87	require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
88	requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
89	requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
90	requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"})
91	requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"})
92}
93
94func TestDefaultConfig_VimModeDeepClone(t *testing.T) {
95	first := DefaultConfig()
96	second := DefaultConfig()
97
98	first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999
99	first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"}
100	delete(first.Workspace.VimMode.Actions, "heading-next")
101
102	assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds)
103	requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"})
104	requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"})
105	require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
106}
107
108func TestVimModeConfig_GetKeyBindings(t *testing.T) {
109	cfg := DefaultConfig()
110	bindings := cfg.Workspace.VimMode.GetKeyBindings()
111
112	assert.Equal(t, "vim-scroll-left", bindings["h"])
113	assert.Equal(t, "vim-scroll-down", bindings["j"])
114	assert.Equal(t, "vim-scroll-up", bindings["k"])
115	assert.Equal(t, "vim-scroll-right", bindings["l"])
116	assert.Equal(t, "vim-scroll-down-fast", bindings["shift+j"])
117	assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])
118	assert.Equal(t, "confirm", bindings["enter"])
119	assert.Equal(t, "cancel", bindings["escape"])
120	assert.Equal(t, "heading-next", bindings["]]"])
121	assert.Equal(t, "heading-prev", bindings["[["])
122	assert.Equal(t, "focus-input", bindings["gi"])
123	assert.Equal(t, "code-next", bindings["]c"])
124	assert.Equal(t, "code-prev", bindings["[c"])
125	assert.Equal(t, "table-next", bindings["]t"])
126	assert.Equal(t, "image-next", bindings["]i"])
127	assert.Equal(t, "list-next", bindings["]l"])
128	assert.Equal(t, "outline", bindings["gO"])
129	assert.Equal(t, "yank-section", bindings["yah"])
130	assert.Equal(t, "half-page-down", bindings["<C-d>"])
131	assert.Equal(t, "half-page-up", bindings["<C-u>"])
132	assert.Len(t, bindings, 20)
133}
134
135func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
136	tests := []struct {
137		name   string
138		mutate func(*Config)
139		want   string
140	}{
141		{
142			name: "negative timeout fails",
143			mutate: func(cfg *Config) {
144				cfg.Workspace.VimMode.TimeoutMilliseconds = -1
145			},
146			want: "workspace.vim_mode.timeout_ms must be non-negative",
147		},
148		{
149			name: "zero timeout ok",
150			mutate: func(_ *Config) {
151			},
152			want: "",
153		},
154		{
155			name: "positive timeout ok",
156			mutate: func(cfg *Config) {
157				cfg.Workspace.VimMode.TimeoutMilliseconds = 5000
158			},
159			want: "",
160		},
161	}
162
163	for _, tt := range tests {
164		t.Run(tt.name, func(t *testing.T) {
165			cfg := DefaultConfig()
166			tt.mutate(cfg)
167			err := validateConfig(cfg)
168			if tt.want == "" {
169				require.NoError(t, err)
170			} else {
171				require.Error(t, err)
172				assert.Contains(t, err.Error(), tt.want)
173			}
174		})
175	}
176}
177
178func TestValidateVimMode_SequenceTimeout(t *testing.T) {
179	tests := []struct {
180		name   string
181		mutate func(*Config)
182		want   string
183	}{
184		{
185			name: "negative sequence timeout fails",
186			mutate: func(cfg *Config) {
187				cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1
188			},
189			want: "workspace.vim_mode.sequence_timeout_ms must be non-negative",
190		},
191		{
192			name: "zero sequence timeout ok",
193			mutate: func(cfg *Config) {
194				cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0
195			},
196			want: "",
197		},
198		{
199			name: "positive sequence timeout ok",
200			mutate: func(cfg *Config) {
201				cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750
202			},
203			want: "",
204		},
205	}
206
207	for _, tt := range tests {
208		t.Run(tt.name, func(t *testing.T) {
209			cfg := DefaultConfig()
210			tt.mutate(cfg)
211			err := validateConfig(cfg)
212			if tt.want == "" {
213				require.NoError(t, err)
214			} else {
215				require.Error(t, err)
216				assert.Contains(t, err.Error(), tt.want)
217			}
218		})
219	}
220}
221
222func TestValidateVimMode_NilActionsFail(t *testing.T) {
223	cfg := DefaultConfig()
224	cfg.Workspace.VimMode.Actions = nil
225
226	err := validateConfig(cfg)
227	require.Error(t, err)
228	assert.Contains(t, err.Error(), "workspace.vim_mode.actions cannot be empty")
229}
230
231func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) {
232	cfg := DefaultConfig()
233	// Add a duplicate key binding that conflicts with an existing one.
234	action := "custom-action"
235	cfg.Workspace.VimMode.Actions[action] = ActionBinding{
236		Keys: []string{"h"}, // "h" is already used by vim-scroll-left
237		Desc: "Custom action",
238	}
239
240	err := validateConfig(cfg)
241	require.Error(t, err)
242	assert.Contains(t, err.Error(), "duplicate key binding 'h' found in vim_mode actions")
243	assert.Contains(t, err.Error(), "vim-scroll-left")
244	assert.Contains(t, err.Error(), action)
245}
246
247func TestValidateVimMode_MalformedBindings(t *testing.T) {
248	malformed := []string{"<C-d", "<Nope>", "<C->"}
249	for _, key := range malformed {
250		t.Run(key, func(t *testing.T) {
251			cfg := DefaultConfig()
252			cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{
253				Keys: []string{key},
254				Desc: "Broken binding",
255			}
256
257			err := validateConfig(cfg)
258			require.Error(t, err)
259			assert.Contains(t, err.Error(), "broken")
260			assert.Contains(t, err.Error(), key)
261		})
262	}
263}
264
265func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) {
266	cfg := DefaultConfig()
267	requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"})
268	requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"})
269	require.NoError(t, validateConfig(cfg))
270
271	assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter"))
272	assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape"))
273	assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>"))
274}
275
276func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
277	tests := []struct {
278		name    string
279		actionA string
280		keysA   []string
281		actionB string
282		keysB   []string
283		canon   string
284	}{
285		{
286			name:    "CR and Return",
287			actionA: "alias-a",
288			keysA:   []string{"<CR>"},
289			actionB: "alias-b",
290			keysB:   []string{"<Return>"},
291			canon:   "<Return>",
292		},
293		{
294			name:    "enter and Return",
295			actionA: "alias-a",
296			keysA:   []string{"enter"},
297			actionB: "alias-b",
298			keysB:   []string{"<Return>"},
299			canon:   "<Return>",
300		},
301		{
302			name:    "escape and Escape",
303			actionA: "alias-a",
304			keysA:   []string{"escape"},
305			actionB: "alias-b",
306			keysB:   []string{"<Escape>"},
307			canon:   "<Escape>",
308		},
309		{
310			name:    "J and shift+j",
311			actionA: "alias-a",
312			keysA:   []string{"J"},
313			actionB: "alias-b",
314			keysB:   []string{"shift+j"},
315			canon:   "J",
316		},
317		{
318			name:    "C-d and ctrl+d",
319			actionA: "alias-a",
320			keysA:   []string{"<C-d>"},
321			actionB: "alias-b",
322			keysB:   []string{"ctrl+d"},
323			canon:   "<C-d>",
324		},
325	}
326
327	for _, tt := range tests {
328		t.Run(tt.name, func(t *testing.T) {
329			cfg := DefaultConfig()
330			// Drop defaults that would collide with the aliases under test.
331			delete(cfg.Workspace.VimMode.Actions, "half-page-down")
332			delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast")
333			delete(cfg.Workspace.VimMode.Actions, "confirm")
334			delete(cfg.Workspace.VimMode.Actions, "cancel")
335			cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"}
336			cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"}
337
338			err := validateConfig(cfg)
339			require.Error(t, err)
340			assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions")
341			assert.Contains(t, err.Error(), tt.actionA)
342			assert.Contains(t, err.Error(), tt.actionB)
343		})
344	}
345}
346
347func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
348	cfg := DefaultConfig()
349	// Override an action with no key bindings.
350	cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
351		"vim-scroll-left": {Keys: []string{}, Desc: "No keys"},
352	}
353
354	err := validateConfig(cfg)
355	require.Error(t, err)
356	assert.Contains(t, err.Error(), "workspace.vim_mode.actions.vim-scroll-left must have at least one key binding")
357}
358
359func TestValidateModalModeActions_RegressionTable(t *testing.T) {
360	tests := []struct {
361		name     string
362		modePath string
363		timeout  int
364		actions  map[string]ActionBinding
365		want     string
366	}{
367		{
368			name:     "page negative timeout",
369			modePath: "vim_mode",
370			timeout:  -1,
371			actions:  map[string]ActionBinding{"vim-scroll-left": {Keys: []string{"h"}}},
372			want:     "workspace.vim_mode.timeout_ms must be non-negative",
373		},
374		{
375			name:     "pane empty actions",
376			modePath: "pane_mode",
377			timeout:  0,
378			actions:  nil,
379			want:     "workspace.pane_mode.actions cannot be empty",
380		},
381		{
382			name:     "tab empty keys",
383			modePath: "tab_mode",
384			timeout:  0,
385			actions:  map[string]ActionBinding{"new-tab": {Keys: []string{}}},
386			want:     "workspace.tab_mode.actions.new-tab must have at least one key binding",
387		},
388		{
389			name:     "page duplicate vim binding",
390			modePath: "vim_mode",
391			timeout:  0,
392			actions: map[string]ActionBinding{
393				"vim-scroll-left":  {Keys: []string{"h"}},
394				"vim-scroll-right": {Keys: []string{"h"}},
395			},
396			want: "duplicate key binding 'h' found in vim_mode actions",
397		},
398		{
399			name:     "page canonical vim set ok",
400			modePath: "vim_mode",
401			timeout:  0,
402			actions: map[string]ActionBinding{
403				"vim-scroll-left":      {Keys: []string{"h"}},
404				"vim-scroll-down":      {Keys: []string{"j"}},
405				"vim-scroll-up":        {Keys: []string{"k"}},
406				"vim-scroll-right":     {Keys: []string{"l"}},
407				"vim-scroll-down-fast": {Keys: []string{"shift+j"}},
408				"vim-scroll-up-fast":   {Keys: []string{"shift+k"}},
409				"confirm":              {Keys: []string{"enter"}},
410				"cancel":               {Keys: []string{"escape"}},
411			},
412			want: "",
413		},
414	}
415
416	for _, tt := range tests {
417		t.Run(tt.name, func(t *testing.T) {
418			errs := validateModalModeActions(tt.modePath, tt.timeout, tt.actions)
419			if tt.want == "" {
420				assert.Empty(t, errs)
421				return
422			}
423			require.NotEmpty(t, errs)
424			assert.Contains(t, errs[0], tt.want)
425		})
426	}
427}
428
429func TestValidateVimMode_DefaultsAccept(t *testing.T) {
430	cfg := DefaultConfig()
431	require.NoError(t, validateConfig(cfg))
432}
433
434func TestSchemaProvider_VimModeKeys(t *testing.T) {
435	provider := &SchemaProvider{}
436	schema := provider.GetSchema()
437
438	var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool
439	for _, key := range schema {
440		switch key.Key {
441		case "workspace.vim_mode.activation_shortcut":
442			foundActivation = true
443			assert.Equal(t, "string", key.Type)
444			assert.Equal(t, "ctrl+y", key.Default)
445			assert.Equal(t, SectionWorkspace, key.Section)
446		case "workspace.vim_mode.timeout_ms":
447			foundTimeout = true
448			assert.Equal(t, "int", key.Type)
449			assert.Equal(t, "0", key.Default)
450			assert.Equal(t, ">=0", key.Range)
451			assert.Equal(t, SectionWorkspace, key.Section)
452		case "workspace.vim_mode.sequence_timeout_ms":
453			foundSequenceTimeout = true
454			assert.Equal(t, "int", key.Type)
455			assert.Equal(t, "500", key.Default)
456			assert.Equal(t, ">=0", key.Range)
457			assert.Equal(t, SectionWorkspace, key.Section)
458		case "workspace.vim_mode.actions.<action>":
459			foundActions = true
460			assert.Equal(t, "object", key.Type)
461			assert.Equal(t, SectionWorkspace, key.Section)
462		}
463	}
464
465	assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")
466	assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")
467	assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key")
468	assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")
469}
470
471func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) {
472	provider := &SchemaProvider{}
473	schema := provider.GetSchema()
474
475	var found bool
476	for _, key := range schema {
477		if key.Key != "workspace.vim_mode.sequence_timeout_ms" {
478			continue
479		}
480		found = true
481		assert.Equal(t, "int", key.Type)
482		assert.Equal(t, "500", key.Default)
483		assert.Equal(t, ">=0", key.Range)
484		assert.Equal(t, SectionWorkspace, key.Section)
485	}
486	assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms")
487}

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