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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 355 | 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 | 362 | ### Pane Mode | |
| 359 | 363 | ||
| 360 | 364 | | 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 Mode | 362 | ### 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 @@ | |||
| 101 | 101 | | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) | | |
| 102 | 102 | | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap | | |
| 103 | 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 | 105 | | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | | | |
| 106 | 106 | | `workspace.pane_mode.timeout_ms` | int | `3000` | | | |
| 107 | 107 | | `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. | |||
| 20 | 20 | ||
| 21 | 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 | 25 | ## Pane Mode (`Ctrl+P`) | |
| 24 | 26 | ||
| 25 | 27 | | 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 { | |||
| 374 | 374 | } | |
| 375 | 375 | ||
| 376 | 376 | func normalizeConfig(config *Config) { | |
| 377 | + | completeDefaultActions(config) | |
| 377 | 378 | normalizeExternalLinks(config) | |
| 378 | 379 | normalizeAppearance(config) | |
| 379 | 380 | 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 | |||
| 36 | 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 | 40 | home := t.TempDir() | |
| 41 | 41 | t.Setenv("HOME", home) | |
| 42 | 42 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| @@ -65,7 +65,7 @@ desc = "Custom stack pane" | |||
| 65 | 65 | require.NotNil(t, cfg) | |
| 66 | 66 | ||
| 67 | 67 | _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"] | |
| 68 | - | assert.False(t, ok) | |
| 68 | + | assert.True(t, ok) | |
| 69 | 69 | ||
| 70 | 70 | stack := cfg.Workspace.PaneMode.Actions["stack-pane"] | |
| 71 | 71 | assert.Equal(t, "Custom stack pane", stack.Desc) | |
| @@ -123,6 +123,8 @@ desc = "Toggle Favorites in right split" | |||
| 123 | 123 | favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"] | |
| 124 | 124 | assert.Empty(t, favorites.Keys) | |
| 125 | 125 | 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) | |
| 128 | 130 | } | |
| @@ -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 ( | |||
| 296 | 296 | ||
| 297 | 297 | type defaultActionMap struct { | |
| 298 | 298 | key string | |
| 299 | + | spec actionMapSpec | |
| 299 | 300 | actions map[string]entity.ActionBinding | |
| 300 | 301 | } | |
| 301 | 302 | ||
| 302 | 303 | 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 | + | }) | |
| 311 | 312 | } | |
| 313 | + | return maps | |
| 312 | 314 | } | |
| 313 | 315 | ||
| 314 | 316 | // typesAreCompatible checks if the default types for two keys are compatible. | |
| @@ -987,7 +989,21 @@ func (*Migrator) isUserDataSection(keyPath string) bool { | |||
| 987 | 989 | 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 | 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 | 1007 | func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string { | |
| 992 | 1008 | if userKeysWithValues == nil { | |
| 993 | 1009 | return nil | |
| @@ -1005,15 +1021,9 @@ func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any | |||
| 1005 | 1021 | continue | |
| 1006 | 1022 | } | |
| 1007 | 1023 | ||
| 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) | |
| 1017 | 1027 | } | |
| 1018 | 1028 | } | |
| 1019 | 1029 | ||
| @@ -1034,15 +1044,9 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) { | |||
| 1034 | 1044 | continue | |
| 1035 | 1045 | } | |
| 1036 | 1046 | ||
| 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] | |
| 1046 | 1050 | } | |
| 1047 | 1051 | ||
| 1048 | 1052 | m.setNestedValue(rawConfig, actionMap.key, userActions) | |
| @@ -296,19 +296,21 @@ const ( | |||||
| 296 | 296 | ||||
| 297 | type defaultActionMap struct { | 297 | type defaultActionMap struct { | ||
| 298 | key string | 298 | key string | ||
| 299 | + | spec actionMapSpec | |||
| 299 | actions map[string]entity.ActionBinding | 300 | 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 false | 989 | 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 nil | 1009 | return nil | ||
| @@ -1005,15 +1021,9 @@ func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any | |||||
| 1005 | continue | 1021 | 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 | - | continue | 1026 | + | 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 | continue | 1044 | 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 | - | continue | 1049 | + | 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( | |||
| 312 | 312 | seenSequence := make(map[string]string) | |
| 313 | 313 | for _, action := range actionNames { | |
| 314 | 314 | 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. | |
| 319 | 316 | keys := append([]string(nil), binding.Keys...) | |
| 320 | 317 | sort.Strings(keys) | |
| 321 | 318 | 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) { | |||
| 357 | 357 | } | |
| 358 | 358 | } | |
| 359 | 359 | ||
| 360 | - | func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) { | |
| 360 | + | func TestValidateVimMode_EmptyActionKeysDisableAction(t *testing.T) { | |
| 361 | 361 | cfg := DefaultConfig() | |
| 362 | 362 | // Override an action with no key bindings. | |
| 363 | 363 | cfg.Workspace.VimMode.Actions = map[string]ActionBinding{ | |
| 364 | 364 | "vim-scroll-left": {Keys: []string{}, Desc: "No keys"}, | |
| 365 | 365 | } | |
| 366 | 366 | ||
| 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)) | |
| 370 | 368 | } | |
| 371 | 369 | ||
| 372 | 370 | func TestValidateModalModeActions_RegressionTable(t *testing.T) { | |
| @@ -391,13 +389,6 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) { | |||
| 391 | 389 | actions: nil, | |
| 392 | 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 | 393 | name: "page duplicate vim binding", | |
| 403 | 394 | 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", | ||