default_actions_test.go RawHistoryFolder

1package config
2
3import (
4	"os"
5	"path/filepath"
6	"testing"
7
8	"github.com/stretchr/testify/assert"
9	"github.com/stretchr/testify/require"
10)
11
12func loadConfigFromTOML(t *testing.T, content string) *Config {
13	t.Helper()
14	home := t.TempDir()
15	t.Setenv("HOME", home)
16	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
17	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
18	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
19	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
20
21	configFile, err := GetConfigFile()
22	require.NoError(t, err)
23	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
24	require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
25
26	mgr, err := NewManager()
27	require.NoError(t, err)
28	require.NoError(t, mgr.Load())
29	return mgr.Get()
30}
31
32// unusedKey returns a valid key sequence that no default action in the map uses.
33func unusedKey(spec actionMapSpec) string {
34	if spec.canonKey != nil {
35		return "<C-x>"
36	}
37	return "ctrl+alt+f12"
38}
39
40func TestActionMapSpecs_CoverEveryActionMap(t *testing.T) {
41	cfg := DefaultConfig()
42	wantKeys := []string{
43		"workspace.shortcuts.actions",
44		"workspace.prefix_mode.actions",
45		"workspace.pane_mode.actions",
46		"workspace.tab_mode.actions",
47		"workspace.vim_mode.actions",
48		"workspace.resize_mode.actions",
49		"session.session_mode.actions",
50	}
51	var gotKeys []string
52	for _, spec := range actionMapSpecs() {
53		gotKeys = append(gotKeys, spec.key)
54		assert.NotEmpty(t, *spec.actions(cfg), spec.key)
55	}
56	assert.ElementsMatch(t, wantKeys, gotKeys)
57}
58
59func TestCompleteDefaultActions_AllMaps(t *testing.T) {
60	for _, spec := range actionMapSpecs() {
61		t.Run(spec.key, func(t *testing.T) {
62			defaults := DefaultConfig()
63			defaultActions := *spec.actions(defaults)
64
65			// Partial user map: a single custom action on an unused key.
66			cfg := DefaultConfig()
67			*spec.actions(cfg) = map[string]ActionBinding{
68				"custom-user-action": {Keys: []string{unusedKey(spec)}, Desc: "custom"},
69			}
70			completeDefaultActions(cfg)
71
72			got := *spec.actions(cfg)
73			assert.Contains(t, got, "custom-user-action")
74			assert.Len(t, got, len(defaultActions)+1)
75			for name, binding := range defaultActions {
76				assert.Equal(t, binding, got[name], name)
77			}
78			require.NoError(t, validateConfig(cfg))
79		})
80	}
81}
82
83func TestCompleteDefaultActions_UserOverrideWins(t *testing.T) {
84	for _, spec := range actionMapSpecs() {
85		t.Run(spec.key, func(t *testing.T) {
86			defaults := DefaultConfig()
87			var name string
88			for n, b := range *spec.actions(defaults) {
89				if len(b.Keys) > 0 {
90					name = n
91					break
92				}
93			}
94			require.NotEmpty(t, name)
95
96			cfg := DefaultConfig()
97			*spec.actions(cfg) = map[string]ActionBinding{
98				name: {Keys: []string{unusedKey(spec)}, Desc: "mine"},
99			}
100			completeDefaultActions(cfg)
101
102			got := (*spec.actions(cfg))[name]
103			assert.Equal(t, []string{unusedKey(spec)}, got.Keys)
104			assert.Equal(t, "mine", got.Desc)
105		})
106	}
107}
108
109func TestCompleteDefaultActions_SkipsDefaultsConflictingWithUserKeys(t *testing.T) {
110	cfg := DefaultConfig()
111	// The user key canonicalizes to the same vim sequence as the default "shift+j"
112	// key of vim-scroll-down-fast; the user binds it to a custom action.
113	cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
114		"my-action": {Keys: []string{"shift+j"}, Desc: "mine"},
115	}
116	completeDefaultActions(cfg)
117
118	got := cfg.Workspace.VimMode.Actions
119	assert.Contains(t, got, "my-action")
120	assert.NotContains(t, got, "vim-scroll-down-fast")
121	assert.Contains(t, got, "vim-scroll-up-fast")
122	require.NoError(t, validateConfig(cfg))
123}
124
125func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) {
126	cfg := DefaultConfig()
127	original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}}
128	cfg.Workspace.TabMode.Actions = original
129	completeDefaultActions(cfg)
130	assert.Len(t, original, 1)
131	assert.Greater(t, len(cfg.Workspace.TabMode.Actions), 1)
132}
133
134func TestManagerLoad_PartialVimActionsGetMergedDefaults(t *testing.T) {
135	cfg := loadConfigFromTOML(t, `
136[workspace.vim_mode.actions.cancel]
137keys = ["escape"]
138desc = "Cancel"
139`)
140	defaults := DefaultConfig().Workspace.VimMode.Actions
141	assert.Len(t, cfg.Workspace.VimMode.Actions, len(defaults))
142	for name := range defaults {
143		assert.Contains(t, cfg.Workspace.VimMode.Actions, name)
144	}
145}
146
147func TestManagerLoad_UserKeysOverrideDefaultAndConflictingDefaultSkipped(t *testing.T) {
148	// Rebind scroll-left to "j", which is the default key of scroll-down.
149	cfg := loadConfigFromTOML(t, `
150[workspace.vim_mode.actions.vim-scroll-left]
151keys = ["j"]
152desc = "Mine"
153`)
154	actions := cfg.Workspace.VimMode.Actions
155	assert.Equal(t, []string{"j"}, actions["vim-scroll-left"].Keys)
156	assert.NotContains(t, actions, "vim-scroll-down")
157	assert.Contains(t, actions, "vim-scroll-up")
158}
159
160func TestManagerLoad_EmptyKeysDisablesActionAndIsNotRefilled(t *testing.T) {
161	cfg := loadConfigFromTOML(t, `
162[workspace.vim_mode.actions.vim-scroll-left]
163keys = []
164desc = "Disabled"
165
166[workspace.pane_mode.actions.stack-pane]
167keys = []
168`)
169	left, ok := cfg.Workspace.VimMode.Actions["vim-scroll-left"]
170	require.True(t, ok)
171	assert.Empty(t, left.Keys)
172	assert.Equal(t, "Disabled", left.Desc)
173
174	stack, ok := cfg.Workspace.PaneMode.Actions["stack-pane"]
175	require.True(t, ok)
176	assert.Empty(t, stack.Keys)
177
178	// Other defaults are still completed.
179	assert.Contains(t, cfg.Workspace.VimMode.Actions, "vim-scroll-right")
180	assert.Contains(t, cfg.Workspace.PaneMode.Actions, "eject-pane-to-window")
181}
182
183func TestValidateModalModeActions_EmptyKeysAreDisabledNotInvalid(t *testing.T) {
184	errs := validateModalModeActions("tab_mode", 0, map[string]ActionBinding{
185		"new-tab": {Keys: []string{}},
186		"other":   {Keys: nil},
187	})
188	assert.Empty(t, errs)
189}
190
191func TestMigrator_DisabledActionIsNotReportedMissing(t *testing.T) {
192	m := NewMigrator()
193	missing := m.detectMissingDefaultActions(map[string]any{
194		"workspace.pane_mode.actions": map[string]any{
195			"stack-pane": map[string]any{"keys": []any{}, "desc": "off"},
196		},
197	})
198	assert.NotContains(t, missing, "workspace.pane_mode.actions.stack-pane")
199	assert.Contains(t, missing, "workspace.pane_mode.actions.eject-pane-to-window")
200}
201
202func TestMigrator_MergeSkipsDefaultsConflictingWithUserKeys(t *testing.T) {
203	m := NewMigrator()
204	raw := map[string]any{
205		"workspace": map[string]any{
206			"vim_mode": map[string]any{
207				"actions": map[string]any{
208					"vim-scroll-left": map[string]any{"keys": []any{"j"}, "desc": "mine"},
209				},
210			},
211		},
212	}
213	m.mergeMissingDefaultActions(raw)
214	actions := m.getNestedValue(raw, "workspace.vim_mode.actions").(map[string]any)
215	assert.NotContains(t, actions, "vim-scroll-down")
216	assert.Contains(t, actions, "vim-scroll-up")
217}
218
219func TestManagerSave_KeepsDisabledActionsDisabledAcrossReload(t *testing.T) {
220	home := t.TempDir()
221	t.Setenv("HOME", home)
222	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
223	t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
224	t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
225	t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
226
227	configFile, err := GetConfigFile()
228	require.NoError(t, err)
229	require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
230	require.NoError(t, os.WriteFile(configFile, []byte(`
231[workspace.vim_mode.actions.cancel]
232keys = ["escape"]
233
234[workspace.pane_mode.actions.stack-pane]
235keys = []
236`), 0o644))
237
238	mgr, err := NewManager()
239	require.NoError(t, err)
240	require.NoError(t, mgr.Load())
241
242	cfg := mgr.Get()
243	require.NoError(t, mgr.Save(cfg))
244
245	reloaded, err := NewManager()
246	require.NoError(t, err)
247	require.NoError(t, reloaded.Load())
248	got := reloaded.Get()
249
250	stack, ok := got.Workspace.PaneMode.Actions["stack-pane"]
251	require.True(t, ok)
252	assert.Empty(t, stack.Keys)
253	assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions))
254}
255
256func TestManagerLoad_UnderscoreActionNamesAreCanonicalizedBeforeCompletion(t *testing.T) {
257	cfg := loadConfigFromTOML(t, `
258[workspace.pane_mode.actions.stack_pane]
259keys = ["ctrl+alt+f12"]
260desc = "Mine"
261
262[workspace.resize_mode.actions.resize_increase_left]
263keys = ["z"]
264
265[workspace.prefix_mode.actions.enter_resize_mode]
266keys = ["ctrl+alt+f11"]
267
268[workspace.pane_mode.actions.focus_left]
269keys = ["ctrl+alt+f10"]
270
271[workspace.shortcuts.actions.toggle_floating_pane]
272keys = ["alt+g"]
273`)
274
275	pane := cfg.Workspace.PaneMode.Actions
276	assert.NotContains(t, pane, "stack_pane")
277	assert.Equal(t, []string{"ctrl+alt+f12"}, pane["stack-pane"].Keys)
278	assert.Equal(t, "Mine", pane["stack-pane"].Desc)
279	assert.NotContains(t, pane, "focus_left")
280	assert.Equal(t, []string{"ctrl+alt+f10"}, pane["focus-left"].Keys)
281
282	resize := cfg.Workspace.ResizeMode.Actions
283	assert.NotContains(t, resize, "resize_increase_left")
284	assert.Equal(t, []string{"z"}, resize["resize-increase-left"].Keys)
285	assert.Contains(t, resize, "resize-increase-right", "other defaults are still completed")
286
287	prefix := cfg.Workspace.PrefixMode.Actions
288	assert.NotContains(t, prefix, "enter_resize_mode")
289	assert.Equal(t, []string{"ctrl+alt+f11"}, prefix["enter-resize-mode"].Keys)
290
291	shortcuts := cfg.Workspace.Shortcuts.Actions
292	assert.NotContains(t, shortcuts, "toggle_floating_pane")
293	assert.Equal(t, []string{"alt+g"}, shortcuts["toggle-floating-pane"].Keys)
294
295	for _, spec := range actionMapSpecs() {
296		for name := range *spec.actions(cfg) {
297			assert.NotContains(t, name, "_", "%s.%s", spec.key, name)
298		}
299	}
300}
301
302func TestManagerLoad_HyphenActionNameWinsOverUnderscoreDuplicate(t *testing.T) {
303	cfg := loadConfigFromTOML(t, `
304[workspace.pane_mode.actions.stack_pane]
305keys = ["ctrl+alt+f12"]
306
307[workspace.pane_mode.actions.stack-pane]
308keys = ["ctrl+alt+f11"]
309`)
310	assert.Equal(t, []string{"ctrl+alt+f11"}, cfg.Workspace.PaneMode.Actions["stack-pane"].Keys)
311	assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack_pane")
312}
313
314func TestCompleteDefaultActions_AliasSpellingsOfAUserKeyBlockTheDefault(t *testing.T) {
315	tests := []struct {
316		name        string
317		userKey     string
318		wantSkipped string
319	}{
320		{name: "right vs arrowright", userKey: "right", wantSkipped: "split-right"},
321		{name: "Right vs arrowright", userKey: "Right", wantSkipped: "split-right"},
322		{name: "uppercase letter vs explicit shift", userKey: "shift+M", wantSkipped: "move-pane-to-next-tab"},
323		{name: "explicit shift vs uppercase letter", userKey: "M", wantSkipped: "move-pane-to-next-tab"},
324	}
325	for _, tt := range tests {
326		t.Run(tt.name, func(t *testing.T) {
327			cfg := DefaultConfig()
328			cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{
329				"close-pane": {Keys: []string{tt.userKey}},
330			}
331			completeDefaultActions(cfg)
332
333			got := cfg.Workspace.PaneMode.Actions
334			assert.Equal(t, []string{tt.userKey}, got["close-pane"].Keys)
335			if tt.wantSkipped != "" {
336				assert.NotContains(t, got, tt.wantSkipped)
337			}
338			require.NoError(t, validateConfig(cfg), "completed config must stay valid")
339		})
340	}
341}
342
343func TestCompleteDefaultActions_ControlSpellingBlocksGlobalDefault(t *testing.T) {
344	cfg := DefaultConfig()
345	cfg.Workspace.Shortcuts.Actions = map[string]ActionBinding{
346		"new-tab": {Keys: []string{"Control+r"}},
347	}
348	completeDefaultActions(cfg)
349
350	// Default reload is ["ctrl+r", "f5"]; "Control+r" is the same key as
351	// "ctrl+r", so the user owns it and reload is not added back.
352	assert.NotContains(t, cfg.Workspace.Shortcuts.Actions, "reload")
353	assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "hard-reload")
354	assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "open-omnibox")
355	require.NoError(t, validateConfig(cfg))
356}
357
358func TestManagerLoad_AliasConflictIsResolvedDeterministically(t *testing.T) {
359	cfg := loadConfigFromTOML(t, `
360[workspace.pane_mode.actions.close-pane]
361keys = ["right"]
362`)
363	pane := cfg.Workspace.PaneMode.Actions
364	assert.Equal(t, []string{"right"}, pane["close-pane"].Keys)
365	assert.NotContains(t, pane, "split-right", "arrowright is the same key as right")
366	assert.Contains(t, pane, "split-left")
367}
368
369func TestValidateModalModeActions_DetectsAliasDuplicates(t *testing.T) {
370	tests := []struct {
371		name      string
372		keysA     string
373		keysB     string
374		wantCanon string
375	}{
376		{name: "right and arrowright", keysA: "right", keysB: "arrowright", wantCanon: "right"},
377		{name: "control and ctrl", keysA: "control+t", keysB: "ctrl+t", wantCanon: "ctrl+t"},
378		{name: "uppercase and explicit shift", keysA: "M", keysB: "shift+m", wantCanon: "shift+m"},
379		{name: "modifier order and case", keysA: "Shift+Ctrl+T", keysB: "ctrl+shift+t", wantCanon: "ctrl+shift+t"},
380	}
381	for _, tt := range tests {
382		t.Run(tt.name, func(t *testing.T) {
383			errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{
384				"action-a": {Keys: []string{tt.keysA}},
385				"action-b": {Keys: []string{tt.keysB}},
386			})
387			require.Len(t, errs, 1)
388			assert.Contains(t, errs[0], "duplicate key binding '"+tt.wantCanon+"' found in pane_mode")
389			assert.Contains(t, errs[0], "action-a")
390			assert.Contains(t, errs[0], "action-b")
391		})
392	}
393}
394
395func TestValidateModalModeActions_SpellingsOfOneKeyInOneActionAreNotDuplicates(t *testing.T) {
396	// The defaults list "M" and "shift+m" together.
397	errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{
398		"move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}},
399	})
400	assert.Empty(t, errs)
401}

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