detached 78e62bd

fix(config): merge missing default actions at load time

Viper replaces a whole default map once the user defines any entry, so
newly added default actions were inactive until `config migrate`.
Complete every action map in memory after unmarshal (load and reload),
letting user keys win on canonical sequence conflicts, and treat
`keys = []` as a disabled action.
commit
78e62bddc779cc519441c6098510079a6f10feec
author
brice · · 2026-10-14 05:38 UTC
parent
0ad4e24
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +464 -46
M docs/config/index.md +4 -0
@@ -355,6 +355,10 @@ Existing configs using older keys are migrated to the current engine shape. New
355355 new_pane_url = "dumb://history"
356356 ```
357357
358+### Action Maps
359+
360+Every action map (`workspace.shortcuts.actions`, `workspace.prefix_mode.actions`, `workspace.pane_mode.actions`, `workspace.tab_mode.actions`, `workspace.vim_mode.actions`, `workspace.resize_mode.actions`, `session.session_mode.actions`) is completed with the default actions your config does not define, so new default actions are active without editing the file. Your bindings win: a default action is skipped when one of its keys is already used by an action you configured in the same map. Set `keys = []` on an action to disable it.
361+
358362 ### Pane Mode
359363
360364 | Key | Type | Default | Description |
@@ -355,6 +355,10 @@ Existing configs using older keys are migrated to the current engine shape. New
355 new_pane_url = "dumb://history"355 new_pane_url = "dumb://history"
356 ```356 ```
357 357
358+### Action Maps
359+
360+Every action map (`workspace.shortcuts.actions`, `workspace.prefix_mode.actions`, `workspace.pane_mode.actions`, `workspace.tab_mode.actions`, `workspace.vim_mode.actions`, `workspace.resize_mode.actions`, `session.session_mode.actions`) is completed with the default actions your config does not define, so new default actions are active without editing the file. Your bindings win: a default action is skipped when one of its keys is already used by an action you configured in the same map. Set `keys = []` on an action to disable it.
361+
358 ### Pane Mode362 ### Pane Mode
359 363
360 | Key | Type | Default | Description |364 | Key | Type | Default | Description |
M docs/reference/configuration.md +1 -1
@@ -101,7 +101,7 @@
101101 | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |
102102 | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |
103103 | `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 |
104-| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix |
104+| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix; every action map keeps default actions you do not define, your keys win on conflict, and `keys = []` disables an action |
105105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
106106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
107107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
@@ -101,7 +101,7 @@
101 | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |101 | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |
102 | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |102 | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |
103 | `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 |103 | `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 |
104-| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix |104+| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix; every action map keeps default actions you do not define, your keys win on conflict, and `keys = []` disables an action |
105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
M docs/reference/keybindings.md +2 -0
@@ -20,6 +20,8 @@ Press `Escape` or `Enter` to exit any mode.
2020
2121 Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.
2222
23+Default actions missing from your config are always active. Your own bindings override defaults (a default is skipped if one of its keys is already used in the same mode), and `keys = []` disables an action.
24+
2325 ## Pane Mode (`Ctrl+P`)
2426
2527 | Action | Keys |
@@ -20,6 +20,8 @@ Press `Escape` or `Enter` to exit any mode.
20 20
21 Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.21 Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.
22 22
23+Default actions missing from your config are always active. Your own bindings override defaults (a default is skipped if one of its keys is already used in the same mode), and `keys = []` disables an action.
24+
23 ## Pane Mode (`Ctrl+P`)25 ## Pane Mode (`Ctrl+P`)
24 26
25 | Action | Keys |27 | Action | Keys |
A internal/infrastructure/config/default_actions.go +154 -0
@@ -0,0 +1,154 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "sort"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// actionMapSpec describes one configurable action map. It is the single source
13+// of truth for the list of action maps shared by load-time completion and the
14+// config migrator.
15+type actionMapSpec struct {
16+ // key is the dotted config path of the action map.
17+ key string
18+ // actions returns a pointer to the action map inside a Config.
19+ actions func(*Config) *map[string]ActionBinding
20+ // canonKey canonicalizes a key sequence the way the validator does for
21+ // duplicate detection. nil means keys are compared verbatim.
22+ canonKey func(string) (string, error)
23+}
24+
25+func actionMapSpecs() []actionMapSpec {
26+ return []actionMapSpec{
27+ {key: "workspace.shortcuts.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.Shortcuts.Actions }},
28+ {key: "workspace.prefix_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PrefixMode.Actions }},
29+ {key: "workspace.pane_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PaneMode.Actions }},
30+ {key: "workspace.tab_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.TabMode.Actions }},
31+ {
32+ key: "workspace.vim_mode.actions",
33+ actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.VimMode.Actions },
34+ canonKey: canonicalizeVimModeSequenceKey,
35+ },
36+ {key: "workspace.resize_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.ResizeMode.Actions }},
37+ {key: "session.session_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Session.SessionMode.Actions }},
38+ }
39+}
40+
41+// legacyActionName returns the underscore spelling used by older configs.
42+func legacyActionName(name string) string {
43+ return strings.ReplaceAll(name, "-", "_")
44+}
45+
46+// skippedDefaultAction is a default action not added because a user binding wins.
47+type skippedDefaultAction struct {
48+ name string
49+ sequence string
50+ by string
51+}
52+
53+// missingDefaultActions returns the sorted names of default actions that the
54+// user did not configure (by canonical or legacy underscore name) and whose key
55+// sequences are not already used by a user action. userKeys maps each user
56+// action name to its keys; an action with no keys (disabled) is still present,
57+// so it is never re-added.
58+func (s actionMapSpec) missingDefaultActions(
59+ defaults map[string]ActionBinding,
60+ userKeys map[string][]string,
61+) (missing []string, skipped []skippedDefaultAction) {
62+ canon := s.canonKey
63+ if canon == nil {
64+ canon = func(key string) (string, error) { return key, nil }
65+ }
66+ canonOrRaw := func(key string) string {
67+ if c, err := canon(key); err == nil {
68+ return c
69+ }
70+ return key
71+ }
72+
73+ used := make(map[string]string)
74+ userNames := make([]string, 0, len(userKeys))
75+ for name := range userKeys {
76+ userNames = append(userNames, name)
77+ }
78+ sort.Strings(userNames)
79+ for _, name := range userNames {
80+ for _, key := range userKeys[name] {
81+ used[canonOrRaw(key)] = name
82+ }
83+ }
84+
85+ defaultNames := make([]string, 0, len(defaults))
86+ for name := range defaults {
87+ defaultNames = append(defaultNames, name)
88+ }
89+ sort.Strings(defaultNames)
90+
91+ for _, name := range defaultNames {
92+ if _, ok := userKeys[name]; ok {
93+ continue
94+ }
95+ if _, ok := userKeys[legacyActionName(name)]; ok {
96+ continue
97+ }
98+ conflict := false
99+ for _, key := range defaults[name].Keys {
100+ sequence := canonOrRaw(key)
101+ if by, ok := used[sequence]; ok {
102+ skipped = append(skipped, skippedDefaultAction{name: name, sequence: sequence, by: by})
103+ conflict = true
104+ break
105+ }
106+ }
107+ if !conflict {
108+ missing = append(missing, name)
109+ }
110+ }
111+ return missing, skipped
112+}
113+
114+// completeDefaultActions fills every action map of cfg with the default
115+// actions the user did not configure. User actions always win: a default is
116+// skipped when one of its key sequences is already used by a user action in the
117+// same map. Maps are replaced, never mutated, so copies of cfg sharing them are
118+// unaffected. The config file is not touched.
119+func completeDefaultActions(cfg *Config) {
120+ if cfg == nil {
121+ return
122+ }
123+ defaults := DefaultConfig()
124+ var skippedAll []string
125+ for _, spec := range actionMapSpecs() {
126+ userActions := *spec.actions(cfg)
127+ userKeys := make(map[string][]string, len(userActions))
128+ for name, binding := range userActions {
129+ userKeys[name] = binding.Keys
130+ }
131+
132+ defaultActions := *spec.actions(defaults)
133+ missing, skipped := spec.missingDefaultActions(defaultActions, userKeys)
134+
135+ merged := make(map[string]ActionBinding, len(userActions)+len(missing))
136+ for name, binding := range userActions {
137+ merged[name] = binding
138+ }
139+ for _, name := range missing {
140+ binding := defaultActions[name]
141+ binding.Keys = slices.Clone(binding.Keys)
142+ merged[name] = binding
143+ }
144+ *spec.actions(cfg) = merged
145+
146+ for _, s := range skipped {
147+ skippedAll = append(skippedAll, fmt.Sprintf("%s.%s (key '%s' used by %s)", spec.key, s.name, s.sequence, s.by))
148+ }
149+ }
150+ if len(skippedAll) > 0 {
151+ log := logging.NewFromEnv()
152+ log.Debug().Strs("skipped", skippedAll).Msg("config: default actions not added because user bindings take precedence")
153+ }
154+}
@@ -0,0 +1,154 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "sort"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// actionMapSpec describes one configurable action map. It is the single source
13+// of truth for the list of action maps shared by load-time completion and the
14+// config migrator.
15+type actionMapSpec struct {
16+ // key is the dotted config path of the action map.
17+ key string
18+ // actions returns a pointer to the action map inside a Config.
19+ actions func(*Config) *map[string]ActionBinding
20+ // canonKey canonicalizes a key sequence the way the validator does for
21+ // duplicate detection. nil means keys are compared verbatim.
22+ canonKey func(string) (string, error)
23+}
24+
25+func actionMapSpecs() []actionMapSpec {
26+ return []actionMapSpec{
27+ {key: "workspace.shortcuts.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.Shortcuts.Actions }},
28+ {key: "workspace.prefix_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PrefixMode.Actions }},
29+ {key: "workspace.pane_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PaneMode.Actions }},
30+ {key: "workspace.tab_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.TabMode.Actions }},
31+ {
32+ key: "workspace.vim_mode.actions",
33+ actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.VimMode.Actions },
34+ canonKey: canonicalizeVimModeSequenceKey,
35+ },
36+ {key: "workspace.resize_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.ResizeMode.Actions }},
37+ {key: "session.session_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Session.SessionMode.Actions }},
38+ }
39+}
40+
41+// legacyActionName returns the underscore spelling used by older configs.
42+func legacyActionName(name string) string {
43+ return strings.ReplaceAll(name, "-", "_")
44+}
45+
46+// skippedDefaultAction is a default action not added because a user binding wins.
47+type skippedDefaultAction struct {
48+ name string
49+ sequence string
50+ by string
51+}
52+
53+// missingDefaultActions returns the sorted names of default actions that the
54+// user did not configure (by canonical or legacy underscore name) and whose key
55+// sequences are not already used by a user action. userKeys maps each user
56+// action name to its keys; an action with no keys (disabled) is still present,
57+// so it is never re-added.
58+func (s actionMapSpec) missingDefaultActions(
59+ defaults map[string]ActionBinding,
60+ userKeys map[string][]string,
61+) (missing []string, skipped []skippedDefaultAction) {
62+ canon := s.canonKey
63+ if canon == nil {
64+ canon = func(key string) (string, error) { return key, nil }
65+ }
66+ canonOrRaw := func(key string) string {
67+ if c, err := canon(key); err == nil {
68+ return c
69+ }
70+ return key
71+ }
72+
73+ used := make(map[string]string)
74+ userNames := make([]string, 0, len(userKeys))
75+ for name := range userKeys {
76+ userNames = append(userNames, name)
77+ }
78+ sort.Strings(userNames)
79+ for _, name := range userNames {
80+ for _, key := range userKeys[name] {
81+ used[canonOrRaw(key)] = name
82+ }
83+ }
84+
85+ defaultNames := make([]string, 0, len(defaults))
86+ for name := range defaults {
87+ defaultNames = append(defaultNames, name)
88+ }
89+ sort.Strings(defaultNames)
90+
91+ for _, name := range defaultNames {
92+ if _, ok := userKeys[name]; ok {
93+ continue
94+ }
95+ if _, ok := userKeys[legacyActionName(name)]; ok {
96+ continue
97+ }
98+ conflict := false
99+ for _, key := range defaults[name].Keys {
100+ sequence := canonOrRaw(key)
101+ if by, ok := used[sequence]; ok {
102+ skipped = append(skipped, skippedDefaultAction{name: name, sequence: sequence, by: by})
103+ conflict = true
104+ break
105+ }
106+ }
107+ if !conflict {
108+ missing = append(missing, name)
109+ }
110+ }
111+ return missing, skipped
112+}
113+
114+// completeDefaultActions fills every action map of cfg with the default
115+// actions the user did not configure. User actions always win: a default is
116+// skipped when one of its key sequences is already used by a user action in the
117+// same map. Maps are replaced, never mutated, so copies of cfg sharing them are
118+// unaffected. The config file is not touched.
119+func completeDefaultActions(cfg *Config) {
120+ if cfg == nil {
121+ return
122+ }
123+ defaults := DefaultConfig()
124+ var skippedAll []string
125+ for _, spec := range actionMapSpecs() {
126+ userActions := *spec.actions(cfg)
127+ userKeys := make(map[string][]string, len(userActions))
128+ for name, binding := range userActions {
129+ userKeys[name] = binding.Keys
130+ }
131+
132+ defaultActions := *spec.actions(defaults)
133+ missing, skipped := spec.missingDefaultActions(defaultActions, userKeys)
134+
135+ merged := make(map[string]ActionBinding, len(userActions)+len(missing))
136+ for name, binding := range userActions {
137+ merged[name] = binding
138+ }
139+ for _, name := range missing {
140+ binding := defaultActions[name]
141+ binding.Keys = slices.Clone(binding.Keys)
142+ merged[name] = binding
143+ }
144+ *spec.actions(cfg) = merged
145+
146+ for _, s := range skipped {
147+ skippedAll = append(skippedAll, fmt.Sprintf("%s.%s (key '%s' used by %s)", spec.key, s.name, s.sequence, s.by))
148+ }
149+ }
150+ if len(skippedAll) > 0 {
151+ log := logging.NewFromEnv()
152+ log.Debug().Strs("skipped", skippedAll).Msg("config: default actions not added because user bindings take precedence")
153+ }
154+}
A internal/infrastructure/config/default_actions_test.go +263 -0
@@ -0,0 +1,263 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func 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.
33+func unusedKey(spec actionMapSpec) string {
34+ if spec.canonKey != nil {
35+ return "<C-x>"
36+ }
37+ return "ctrl+alt+f12"
38+}
39+
40+func 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+
59+func 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+
83+func 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+
109+func 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+
125+func TestCompleteDefaultActions_LegacyUnderscoreNameCountsAsConfigured(t *testing.T) {
126+ cfg := DefaultConfig()
127+ cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{
128+ "stack_pane": {Keys: []string{"ctrl+alt+f12"}},
129+ }
130+ completeDefaultActions(cfg)
131+ assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack-pane")
132+}
133+
134+func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) {
135+ cfg := DefaultConfig()
136+ original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}}
137+ cfg.Workspace.TabMode.Actions = original
138+ completeDefaultActions(cfg)
139+ assert.Len(t, original, 1)
140+ assert.Greater(t, len(cfg.Workspace.TabMode.Actions), 1)
141+}
142+
143+func TestManagerLoad_PartialVimActionsGetMergedDefaults(t *testing.T) {
144+ cfg := loadConfigFromTOML(t, `
145+[workspace.vim_mode.actions.cancel]
146+keys = ["escape"]
147+desc = "Cancel"
148+`)
149+ defaults := DefaultConfig().Workspace.VimMode.Actions
150+ assert.Len(t, cfg.Workspace.VimMode.Actions, len(defaults))
151+ for name := range defaults {
152+ assert.Contains(t, cfg.Workspace.VimMode.Actions, name)
153+ }
154+}
155+
156+func TestManagerLoad_UserKeysOverrideDefaultAndConflictingDefaultSkipped(t *testing.T) {
157+ // Rebind scroll-left to "j", which is the default key of scroll-down.
158+ cfg := loadConfigFromTOML(t, `
159+[workspace.vim_mode.actions.vim-scroll-left]
160+keys = ["j"]
161+desc = "Mine"
162+`)
163+ actions := cfg.Workspace.VimMode.Actions
164+ assert.Equal(t, []string{"j"}, actions["vim-scroll-left"].Keys)
165+ assert.NotContains(t, actions, "vim-scroll-down")
166+ assert.Contains(t, actions, "vim-scroll-up")
167+}
168+
169+func TestManagerLoad_EmptyKeysDisablesActionAndIsNotRefilled(t *testing.T) {
170+ cfg := loadConfigFromTOML(t, `
171+[workspace.vim_mode.actions.vim-scroll-left]
172+keys = []
173+desc = "Disabled"
174+
175+[workspace.pane_mode.actions.stack-pane]
176+keys = []
177+`)
178+ left, ok := cfg.Workspace.VimMode.Actions["vim-scroll-left"]
179+ require.True(t, ok)
180+ assert.Empty(t, left.Keys)
181+ assert.Equal(t, "Disabled", left.Desc)
182+
183+ stack, ok := cfg.Workspace.PaneMode.Actions["stack-pane"]
184+ require.True(t, ok)
185+ assert.Empty(t, stack.Keys)
186+
187+ // Other defaults are still completed.
188+ assert.Contains(t, cfg.Workspace.VimMode.Actions, "vim-scroll-right")
189+ assert.Contains(t, cfg.Workspace.PaneMode.Actions, "eject-pane-to-window")
190+}
191+
192+func TestValidateModalModeActions_EmptyKeysAreDisabledNotInvalid(t *testing.T) {
193+ errs := validateModalModeActions("tab_mode", 0, map[string]ActionBinding{
194+ "new-tab": {Keys: []string{}},
195+ "other": {Keys: nil},
196+ })
197+ assert.Empty(t, errs)
198+}
199+
200+func TestMigrator_DisabledActionIsNotReportedMissing(t *testing.T) {
201+ m := NewMigrator()
202+ missing := m.detectMissingDefaultActions(map[string]any{
203+ "workspace.pane_mode.actions": map[string]any{
204+ "stack-pane": map[string]any{"keys": []any{}, "desc": "off"},
205+ },
206+ })
207+ assert.NotContains(t, missing, "workspace.pane_mode.actions.stack-pane")
208+ assert.Contains(t, missing, "workspace.pane_mode.actions.eject-pane-to-window")
209+}
210+
211+func TestMigrator_MergeSkipsDefaultsConflictingWithUserKeys(t *testing.T) {
212+ m := NewMigrator()
213+ raw := map[string]any{
214+ "workspace": map[string]any{
215+ "vim_mode": map[string]any{
216+ "actions": map[string]any{
217+ "vim-scroll-left": map[string]any{"keys": []any{"j"}, "desc": "mine"},
218+ },
219+ },
220+ },
221+ }
222+ m.mergeMissingDefaultActions(raw)
223+ actions := m.getNestedValue(raw, "workspace.vim_mode.actions").(map[string]any)
224+ assert.NotContains(t, actions, "vim-scroll-down")
225+ assert.Contains(t, actions, "vim-scroll-up")
226+}
227+
228+func TestManagerSave_KeepsDisabledActionsDisabledAcrossReload(t *testing.T) {
229+ home := t.TempDir()
230+ t.Setenv("HOME", home)
231+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
232+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
233+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
234+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
235+
236+ configFile, err := GetConfigFile()
237+ require.NoError(t, err)
238+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
239+ require.NoError(t, os.WriteFile(configFile, []byte(`
240+[workspace.vim_mode.actions.cancel]
241+keys = ["escape"]
242+
243+[workspace.pane_mode.actions.stack-pane]
244+keys = []
245+`), 0o644))
246+
247+ mgr, err := NewManager()
248+ require.NoError(t, err)
249+ require.NoError(t, mgr.Load())
250+
251+ cfg := mgr.Get()
252+ require.NoError(t, mgr.Save(cfg))
253+
254+ reloaded, err := NewManager()
255+ require.NoError(t, err)
256+ require.NoError(t, reloaded.Load())
257+ got := reloaded.Get()
258+
259+ stack, ok := got.Workspace.PaneMode.Actions["stack-pane"]
260+ require.True(t, ok)
261+ assert.Empty(t, stack.Keys)
262+ assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions))
263+}
@@ -0,0 +1,263 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func 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.
33+func unusedKey(spec actionMapSpec) string {
34+ if spec.canonKey != nil {
35+ return "<C-x>"
36+ }
37+ return "ctrl+alt+f12"
38+}
39+
40+func 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+
59+func 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+
83+func 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+
109+func 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+
125+func TestCompleteDefaultActions_LegacyUnderscoreNameCountsAsConfigured(t *testing.T) {
126+ cfg := DefaultConfig()
127+ cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{
128+ "stack_pane": {Keys: []string{"ctrl+alt+f12"}},
129+ }
130+ completeDefaultActions(cfg)
131+ assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack-pane")
132+}
133+
134+func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) {
135+ cfg := DefaultConfig()
136+ original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}}
137+ cfg.Workspace.TabMode.Actions = original
138+ completeDefaultActions(cfg)
139+ assert.Len(t, original, 1)
140+ assert.Greater(t, len(cfg.Workspace.TabMode.Actions), 1)
141+}
142+
143+func TestManagerLoad_PartialVimActionsGetMergedDefaults(t *testing.T) {
144+ cfg := loadConfigFromTOML(t, `
145+[workspace.vim_mode.actions.cancel]
146+keys = ["escape"]
147+desc = "Cancel"
148+`)
149+ defaults := DefaultConfig().Workspace.VimMode.Actions
150+ assert.Len(t, cfg.Workspace.VimMode.Actions, len(defaults))
151+ for name := range defaults {
152+ assert.Contains(t, cfg.Workspace.VimMode.Actions, name)
153+ }
154+}
155+
156+func TestManagerLoad_UserKeysOverrideDefaultAndConflictingDefaultSkipped(t *testing.T) {
157+ // Rebind scroll-left to "j", which is the default key of scroll-down.
158+ cfg := loadConfigFromTOML(t, `
159+[workspace.vim_mode.actions.vim-scroll-left]
160+keys = ["j"]
161+desc = "Mine"
162+`)
163+ actions := cfg.Workspace.VimMode.Actions
164+ assert.Equal(t, []string{"j"}, actions["vim-scroll-left"].Keys)
165+ assert.NotContains(t, actions, "vim-scroll-down")
166+ assert.Contains(t, actions, "vim-scroll-up")
167+}
168+
169+func TestManagerLoad_EmptyKeysDisablesActionAndIsNotRefilled(t *testing.T) {
170+ cfg := loadConfigFromTOML(t, `
171+[workspace.vim_mode.actions.vim-scroll-left]
172+keys = []
173+desc = "Disabled"
174+
175+[workspace.pane_mode.actions.stack-pane]
176+keys = []
177+`)
178+ left, ok := cfg.Workspace.VimMode.Actions["vim-scroll-left"]
179+ require.True(t, ok)
180+ assert.Empty(t, left.Keys)
181+ assert.Equal(t, "Disabled", left.Desc)
182+
183+ stack, ok := cfg.Workspace.PaneMode.Actions["stack-pane"]
184+ require.True(t, ok)
185+ assert.Empty(t, stack.Keys)
186+
187+ // Other defaults are still completed.
188+ assert.Contains(t, cfg.Workspace.VimMode.Actions, "vim-scroll-right")
189+ assert.Contains(t, cfg.Workspace.PaneMode.Actions, "eject-pane-to-window")
190+}
191+
192+func TestValidateModalModeActions_EmptyKeysAreDisabledNotInvalid(t *testing.T) {
193+ errs := validateModalModeActions("tab_mode", 0, map[string]ActionBinding{
194+ "new-tab": {Keys: []string{}},
195+ "other": {Keys: nil},
196+ })
197+ assert.Empty(t, errs)
198+}
199+
200+func TestMigrator_DisabledActionIsNotReportedMissing(t *testing.T) {
201+ m := NewMigrator()
202+ missing := m.detectMissingDefaultActions(map[string]any{
203+ "workspace.pane_mode.actions": map[string]any{
204+ "stack-pane": map[string]any{"keys": []any{}, "desc": "off"},
205+ },
206+ })
207+ assert.NotContains(t, missing, "workspace.pane_mode.actions.stack-pane")
208+ assert.Contains(t, missing, "workspace.pane_mode.actions.eject-pane-to-window")
209+}
210+
211+func TestMigrator_MergeSkipsDefaultsConflictingWithUserKeys(t *testing.T) {
212+ m := NewMigrator()
213+ raw := map[string]any{
214+ "workspace": map[string]any{
215+ "vim_mode": map[string]any{
216+ "actions": map[string]any{
217+ "vim-scroll-left": map[string]any{"keys": []any{"j"}, "desc": "mine"},
218+ },
219+ },
220+ },
221+ }
222+ m.mergeMissingDefaultActions(raw)
223+ actions := m.getNestedValue(raw, "workspace.vim_mode.actions").(map[string]any)
224+ assert.NotContains(t, actions, "vim-scroll-down")
225+ assert.Contains(t, actions, "vim-scroll-up")
226+}
227+
228+func TestManagerSave_KeepsDisabledActionsDisabledAcrossReload(t *testing.T) {
229+ home := t.TempDir()
230+ t.Setenv("HOME", home)
231+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
232+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
233+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
234+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
235+
236+ configFile, err := GetConfigFile()
237+ require.NoError(t, err)
238+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
239+ require.NoError(t, os.WriteFile(configFile, []byte(`
240+[workspace.vim_mode.actions.cancel]
241+keys = ["escape"]
242+
243+[workspace.pane_mode.actions.stack-pane]
244+keys = []
245+`), 0o644))
246+
247+ mgr, err := NewManager()
248+ require.NoError(t, err)
249+ require.NoError(t, mgr.Load())
250+
251+ cfg := mgr.Get()
252+ require.NoError(t, mgr.Save(cfg))
253+
254+ reloaded, err := NewManager()
255+ require.NoError(t, err)
256+ require.NoError(t, reloaded.Load())
257+ got := reloaded.Get()
258+
259+ stack, ok := got.Workspace.PaneMode.Actions["stack-pane"]
260+ require.True(t, ok)
261+ assert.Empty(t, stack.Keys)
262+ assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions))
263+}
M internal/infrastructure/config/loader.go +1 -0
@@ -374,6 +374,7 @@ func ensureDatabasePath(config *Config) error {
374374 }
375375
376376 func normalizeConfig(config *Config) {
377+ completeDefaultActions(config)
377378 normalizeExternalLinks(config)
378379 normalizeAppearance(config)
379380 normalizeMedia(config)
@@ -374,6 +374,7 @@ func ensureDatabasePath(config *Config) error {
374 }374 }
375 375
376 func normalizeConfig(config *Config) {376 func normalizeConfig(config *Config) {
377+ completeDefaultActions(config)
377 normalizeExternalLinks(config)378 normalizeExternalLinks(config)
378 normalizeAppearance(config)379 normalizeAppearance(config)
379 normalizeMedia(config)380 normalizeMedia(config)
M internal/infrastructure/config/loader_actions_test.go +6 -4
@@ -36,7 +36,7 @@ touchpad_navigation_max_vertical_ratio = 2.0
3636 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
3737 }
3838
39-func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {
39+func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
4040 home := t.TempDir()
4141 t.Setenv("HOME", home)
4242 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -65,7 +65,7 @@ desc = "Custom stack pane"
6565 require.NotNil(t, cfg)
6666
6767 _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- assert.False(t, ok)
68+ assert.True(t, ok)
6969
7070 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
7171 assert.Equal(t, "Custom stack pane", stack.Desc)
@@ -123,6 +123,8 @@ desc = "Toggle Favorites in right split"
123123 favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
124124 assert.Empty(t, favorites.Keys)
125125 assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)
126- _, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
127- assert.False(t, ok)
126+ // The renamed default action is a missing default, so it is active in memory.
127+ sidebar, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
128+ assert.True(t, ok)
129+ assert.NotEmpty(t, sidebar.Keys)
128130 }
@@ -36,7 +36,7 @@ touchpad_navigation_max_vertical_ratio = 2.0
36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
37 }37 }
38 38
39-func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {39+func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
40 home := t.TempDir()40 home := t.TempDir()
41 t.Setenv("HOME", home)41 t.Setenv("HOME", home)
42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -65,7 +65,7 @@ desc = "Custom stack pane"
65 require.NotNil(t, cfg)65 require.NotNil(t, cfg)
66 66
67 _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]67 _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- assert.False(t, ok)68+ assert.True(t, ok)
69 69
70 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]70 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
71 assert.Equal(t, "Custom stack pane", stack.Desc)71 assert.Equal(t, "Custom stack pane", stack.Desc)
@@ -123,6 +123,8 @@ desc = "Toggle Favorites in right split"
123 favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]123 favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
124 assert.Empty(t, favorites.Keys)124 assert.Empty(t, favorites.Keys)
125 assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)125 assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)
126- _, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]126+ // The renamed default action is a missing default, so it is active in memory.
127- assert.False(t, ok)127+ sidebar, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
128+ assert.True(t, ok)
129+ assert.NotEmpty(t, sidebar.Keys)
128 }130 }
M internal/infrastructure/config/migrate.go +30 -26
@@ -296,19 +296,21 @@ const (
296296
297297 type defaultActionMap struct {
298298 key string
299+ spec actionMapSpec
299300 actions map[string]entity.ActionBinding
300301 }
301302
302303 func (m *Migrator) defaultActionMaps() []defaultActionMap {
303- return []defaultActionMap{
304- {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
305- {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions},
306- {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
307- {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
308- {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
309- {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
310- {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
304+ specs := actionMapSpecs()
305+ maps := make([]defaultActionMap, 0, len(specs))
306+ for _, spec := range specs {
307+ maps = append(maps, defaultActionMap{
308+ key: spec.key,
309+ spec: spec,
310+ actions: *spec.actions(m.defaultConfig),
311+ })
311312 }
313+ return maps
312314 }
313315
314316 // typesAreCompatible checks if the default types for two keys are compatible.
@@ -987,7 +989,21 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
987989 return false
988990 }
989991
992+// userActionKeys extracts name -> keys from a raw user action table. Entries
993+// that cannot be parsed are still reported (with no keys) so they count as
994+// configured.
995+func (m *Migrator) userActionKeys(userActions map[string]any) map[string][]string {
996+ out := make(map[string][]string, len(userActions))
997+ for name, value := range userActions {
998+ binding, _ := m.actionBindingFromAny(value)
999+ out[name] = binding.Keys
1000+ }
1001+ return out
1002+}
1003+
9901004 // detectMissingDefaultActions finds default shortcut/modal actions not present in user config.
1005+// Defaults whose keys are already used by a user action are not reported because
1006+// they are never added.
9911007 func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
9921008 if userKeysWithValues == nil {
9931009 return nil
@@ -1005,15 +1021,9 @@ func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any
10051021 continue
10061022 }
10071023
1008- for actionName := range actionMap.actions {
1009- if _, exists := userActions[actionName]; exists {
1010- continue
1011- }
1012- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1013- if _, exists := userActions[legacyActionName]; exists {
1014- continue
1015- }
1016- missing = append(missing, actionMap.key+"."+actionName)
1024+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1025+ for _, name := range names {
1026+ missing = append(missing, actionMap.key+"."+name)
10171027 }
10181028 }
10191029
@@ -1034,15 +1044,9 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
10341044 continue
10351045 }
10361046
1037- for actionName, defaultAction := range actionMap.actions {
1038- if _, exists := userActions[actionName]; exists {
1039- continue
1040- }
1041- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1042- if _, exists := userActions[legacyActionName]; exists {
1043- continue
1044- }
1045- userActions[actionName] = defaultAction
1047+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1048+ for _, name := range names {
1049+ userActions[name] = actionMap.actions[name]
10461050 }
10471051
10481052 m.setNestedValue(rawConfig, actionMap.key, userActions)
@@ -296,19 +296,21 @@ const (
296 296
297 type defaultActionMap struct {297 type defaultActionMap struct {
298 key string298 key string
299+ spec actionMapSpec
299 actions map[string]entity.ActionBinding300 actions map[string]entity.ActionBinding
300 }301 }
301 302
302 func (m *Migrator) defaultActionMaps() []defaultActionMap {303 func (m *Migrator) defaultActionMaps() []defaultActionMap {
303- return []defaultActionMap{304+ specs := actionMapSpecs()
304- {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},305+ maps := make([]defaultActionMap, 0, len(specs))
305- {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions},306+ for _, spec := range specs {
306- {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},307+ maps = append(maps, defaultActionMap{
307- {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},308+ key: spec.key,
308- {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},309+ spec: spec,
309- {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},310+ actions: *spec.actions(m.defaultConfig),
310- {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},311+ })
311 }312 }
313+ return maps
312 }314 }
313 315
314 // typesAreCompatible checks if the default types for two keys are compatible.316 // typesAreCompatible checks if the default types for two keys are compatible.
@@ -987,7 +989,21 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
987 return false989 return false
988 }990 }
989 991
992+// userActionKeys extracts name -> keys from a raw user action table. Entries
993+// that cannot be parsed are still reported (with no keys) so they count as
994+// configured.
995+func (m *Migrator) userActionKeys(userActions map[string]any) map[string][]string {
996+ out := make(map[string][]string, len(userActions))
997+ for name, value := range userActions {
998+ binding, _ := m.actionBindingFromAny(value)
999+ out[name] = binding.Keys
1000+ }
1001+ return out
1002+}
1003+
990 // detectMissingDefaultActions finds default shortcut/modal actions not present in user config.1004 // detectMissingDefaultActions finds default shortcut/modal actions not present in user config.
1005+// Defaults whose keys are already used by a user action are not reported because
1006+// they are never added.
991 func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {1007 func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
992 if userKeysWithValues == nil {1008 if userKeysWithValues == nil {
993 return nil1009 return nil
@@ -1005,15 +1021,9 @@ func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any
1005 continue1021 continue
1006 }1022 }
1007 1023
1008- for actionName := range actionMap.actions {1024+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1009- if _, exists := userActions[actionName]; exists {1025+ for _, name := range names {
1010- continue1026+ missing = append(missing, actionMap.key+"."+name)
1011- }
1012- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1013- if _, exists := userActions[legacyActionName]; exists {
1014- continue
1015- }
1016- missing = append(missing, actionMap.key+"."+actionName)
1017 }1027 }
1018 }1028 }
1019 1029
@@ -1034,15 +1044,9 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
1034 continue1044 continue
1035 }1045 }
1036 1046
1037- for actionName, defaultAction := range actionMap.actions {1047+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1038- if _, exists := userActions[actionName]; exists {1048+ for _, name := range names {
1039- continue1049+ userActions[name] = actionMap.actions[name]
1040- }
1041- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1042- if _, exists := userActions[legacyActionName]; exists {
1043- continue
1044- }
1045- userActions[actionName] = defaultAction
1046 }1050 }
1047 1051
1048 m.setNestedValue(rawConfig, actionMap.key, userActions)1052 m.setNestedValue(rawConfig, actionMap.key, userActions)
M internal/infrastructure/config/validation.go +1 -4
@@ -312,10 +312,7 @@ func validateModalModeActionsWithKeyCanon(
312312 seenSequence := make(map[string]string)
313313 for _, action := range actionNames {
314314 binding := actions[action]
315- if len(binding.Keys) == 0 {
316- validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
317- continue
318- }
315+ // An action with no keys is disabled; it binds nothing and is valid.
319316 keys := append([]string(nil), binding.Keys...)
320317 sort.Strings(keys)
321318 for _, key := range keys {
@@ -312,10 +312,7 @@ func validateModalModeActionsWithKeyCanon(
312 seenSequence := make(map[string]string)312 seenSequence := make(map[string]string)
313 for _, action := range actionNames {313 for _, action := range actionNames {
314 binding := actions[action]314 binding := actions[action]
315- if len(binding.Keys) == 0 {315+ // An action with no keys is disabled; it binds nothing and is valid.
316- validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
317- continue
318- }
319 keys := append([]string(nil), binding.Keys...)316 keys := append([]string(nil), binding.Keys...)
320 sort.Strings(keys)317 sort.Strings(keys)
321 for _, key := range keys {318 for _, key := range keys {
M internal/infrastructure/config/vim_mode_config_test.go +2 -11
@@ -357,16 +357,14 @@ func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
357357 }
358358 }
359359
360-func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
360+func TestValidateVimMode_EmptyActionKeysDisableAction(t *testing.T) {
361361 cfg := DefaultConfig()
362362 // Override an action with no key bindings.
363363 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
364364 "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},
365365 }
366366
367- err := validateConfig(cfg)
368- require.Error(t, err)
369- assert.Contains(t, err.Error(), "workspace.vim_mode.actions.vim-scroll-left must have at least one key binding")
367+ require.NoError(t, validateConfig(cfg))
370368 }
371369
372370 func TestValidateModalModeActions_RegressionTable(t *testing.T) {
@@ -391,13 +389,6 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
391389 actions: nil,
392390 want: "workspace.pane_mode.actions cannot be empty",
393391 },
394- {
395- name: "tab empty keys",
396- modePath: "tab_mode",
397- timeout: 0,
398- actions: map[string]ActionBinding{"new-tab": {Keys: []string{}}},
399- want: "workspace.tab_mode.actions.new-tab must have at least one key binding",
400- },
401392 {
402393 name: "page duplicate vim binding",
403394 modePath: "vim_mode",
@@ -357,16 +357,14 @@ func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
357 }357 }
358 }358 }
359 359
360-func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {360+func TestValidateVimMode_EmptyActionKeysDisableAction(t *testing.T) {
361 cfg := DefaultConfig()361 cfg := DefaultConfig()
362 // Override an action with no key bindings.362 // Override an action with no key bindings.
363 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{363 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
364 "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},364 "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},
365 }365 }
366 366
367- err := validateConfig(cfg)367+ require.NoError(t, validateConfig(cfg))
368- require.Error(t, err)
369- assert.Contains(t, err.Error(), "workspace.vim_mode.actions.vim-scroll-left must have at least one key binding")
370 }368 }
371 369
372 func TestValidateModalModeActions_RegressionTable(t *testing.T) {370 func TestValidateModalModeActions_RegressionTable(t *testing.T) {
@@ -391,13 +389,6 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
391 actions: nil,389 actions: nil,
392 want: "workspace.pane_mode.actions cannot be empty",390 want: "workspace.pane_mode.actions cannot be empty",
393 },391 },
394- {
395- name: "tab empty keys",
396- modePath: "tab_mode",
397- timeout: 0,
398- actions: map[string]ActionBinding{"new-tab": {Keys: []string{}}},
399- want: "workspace.tab_mode.actions.new-tab must have at least one key binding",
400- },
401 {392 {
402 name: "page duplicate vim binding",393 name: "page duplicate vim binding",
403 modePath: "vim_mode",394 modePath: "vim_mode",

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