v0.33.2 57f21d6

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

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