feat(config): add page mode sequence bindings
Co-authored-by: Cursor <cursoragent@cursor.com>
- commit
- 5ea141f9b6a00493d720485f4777ab043a306ad1
- author
- brice · · 2026-07-28 17:59 UTC
- committer
- bnema · · 2026-08-14 02:42 UTC
- parent
- d1365c4
- signature
- unsigned
7 files · +384 -18
M internal/domain/entity/config_types.go +4 -3
| @@ -44,9 +44,10 @@ type ActionBinding struct { | |||
| 44 | 44 | ||
| 45 | 45 | // PaneModeConfig holds pane mode shortcut configuration. | |
| 46 | 46 | type PaneModeConfig struct { | |
| 47 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 48 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 49 | - | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 47 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 48 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 49 | + | SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line | |
| 50 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | // keyBindingsFromActions converts an action binding map to a key→action map. | |
| @@ -44,9 +44,10 @@ type ActionBinding struct { | |||||
| 44 | 44 | ||||
| 45 | // PaneModeConfig holds pane mode shortcut configuration. | 45 | // PaneModeConfig holds pane mode shortcut configuration. | ||
| 46 | type PaneModeConfig struct { | 46 | type PaneModeConfig struct { | ||
| 47 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 47 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 48 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 48 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 49 | - | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 49 | + | SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line |
| 50 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |||
| 50 | } | 51 | } | ||
| 51 | 52 | ||||
| 52 | // keyBindingsFromActions converts an action binding map to a key→action map. | 53 | // keyBindingsFromActions converts an action binding map to a key→action map. | ||
M internal/infrastructure/config/defaults.go +17 -4
| @@ -44,8 +44,9 @@ const ( | |||
| 44 | 44 | defaultTabTimeoutMilliseconds = 3000 | |
| 45 | 45 | defaultResizeActivationShortcut = "ctrl+n" | |
| 46 | 46 | defaultResizeTimeoutMilliseconds = 3000 | |
| 47 | - | defaultPageActivationShortcut = "ctrl+y" | |
| 48 | - | defaultPageTimeoutMilliseconds = 0 | |
| 47 | + | defaultPageActivationShortcut = "ctrl+y" | |
| 48 | + | defaultPageTimeoutMilliseconds = 0 | |
| 49 | + | defaultPageSequenceTimeoutMilliseconds = 500 | |
| 49 | 50 | defaultResizeStepPercent = 5.0 | |
| 50 | 51 | defaultResizeMinPanePercent = 10.0 | |
| 51 | 52 | defaultTabBarPosition = "bottom" | |
| @@ -305,8 +306,9 @@ func DefaultConfig() *Config { | |||
| 305 | 306 | }, | |
| 306 | 307 | }, | |
| 307 | 308 | VimMode: VimModeConfig{ | |
| 308 | - | ActivationShortcut: defaultPageActivationShortcut, | |
| 309 | - | TimeoutMilliseconds: defaultPageTimeoutMilliseconds, | |
| 309 | + | ActivationShortcut: defaultPageActivationShortcut, | |
| 310 | + | TimeoutMilliseconds: defaultPageTimeoutMilliseconds, | |
| 311 | + | SequenceTimeoutMilliseconds: defaultPageSequenceTimeoutMilliseconds, | |
| 310 | 312 | Actions: map[string]ActionBinding{ | |
| 311 | 313 | "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"}, | |
| 312 | 314 | "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"}, | |
| @@ -316,6 +318,17 @@ func DefaultConfig() *Config { | |||
| 316 | 318 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | |
| 317 | 319 | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |
| 318 | 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |
| 321 | + | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | |
| 322 | + | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | |
| 323 | + | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | |
| 324 | + | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | |
| 325 | + | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | |
| 326 | + | "image-next": {Keys: []string{"]i"}, Desc: "Jump to next image"}, | |
| 327 | + | "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"}, | |
| 328 | + | "outline": {Keys: []string{"gO"}, Desc: "Show page outline"}, | |
| 329 | + | "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"}, | |
| 330 | + | "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"}, | |
| 331 | + | "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"}, | |
| 319 | 332 | }, | |
| 320 | 333 | }, | |
| 321 | 334 | ResizeMode: ResizeModeConfig{ | |
| @@ -44,8 +44,9 @@ const ( | |||||
| 44 | defaultTabTimeoutMilliseconds = 3000 | 44 | defaultTabTimeoutMilliseconds = 3000 | ||
| 45 | defaultResizeActivationShortcut = "ctrl+n" | 45 | defaultResizeActivationShortcut = "ctrl+n" | ||
| 46 | defaultResizeTimeoutMilliseconds = 3000 | 46 | defaultResizeTimeoutMilliseconds = 3000 | ||
| 47 | - | defaultPageActivationShortcut = "ctrl+y" | 47 | + | defaultPageActivationShortcut = "ctrl+y" |
| 48 | - | defaultPageTimeoutMilliseconds = 0 | 48 | + | defaultPageTimeoutMilliseconds = 0 |
| 49 | + | defaultPageSequenceTimeoutMilliseconds = 500 | |||
| 49 | defaultResizeStepPercent = 5.0 | 50 | defaultResizeStepPercent = 5.0 | ||
| 50 | defaultResizeMinPanePercent = 10.0 | 51 | defaultResizeMinPanePercent = 10.0 | ||
| 51 | defaultTabBarPosition = "bottom" | 52 | defaultTabBarPosition = "bottom" | ||
| @@ -305,8 +306,9 @@ func DefaultConfig() *Config { | |||||
| 305 | }, | 306 | }, | ||
| 306 | }, | 307 | }, | ||
| 307 | VimMode: VimModeConfig{ | 308 | VimMode: VimModeConfig{ | ||
| 308 | - | ActivationShortcut: defaultPageActivationShortcut, | 309 | + | ActivationShortcut: defaultPageActivationShortcut, |
| 309 | - | TimeoutMilliseconds: defaultPageTimeoutMilliseconds, | 310 | + | TimeoutMilliseconds: defaultPageTimeoutMilliseconds, |
| 311 | + | SequenceTimeoutMilliseconds: defaultPageSequenceTimeoutMilliseconds, | |||
| 310 | Actions: map[string]ActionBinding{ | 312 | Actions: map[string]ActionBinding{ | ||
| 311 | "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"}, | 313 | "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"}, | ||
| 312 | "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"}, | 314 | "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"}, | ||
| @@ -316,6 +318,17 @@ func DefaultConfig() *Config { | |||||
| 316 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | 318 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | ||
| 317 | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | 319 | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | ||
| 318 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | ||
| 321 | + | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | |||
| 322 | + | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | |||
| 323 | + | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | |||
| 324 | + | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | |||
| 325 | + | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | |||
| 326 | + | "image-next": {Keys: []string{"]i"}, Desc: "Jump to next image"}, | |||
| 327 | + | "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"}, | |||
| 328 | + | "outline": {Keys: []string{"gO"}, Desc: "Show page outline"}, | |||
| 329 | + | "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"}, | |||
| 330 | + | "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"}, | |||
| 331 | + | "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"}, | |||
| 319 | }, | 332 | }, | ||
| 320 | }, | 333 | }, | ||
| 321 | ResizeMode: ResizeModeConfig{ | 334 | ResizeMode: ResizeModeConfig{ | ||
M internal/infrastructure/config/loader.go +1 -0
| @@ -708,6 +708,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 708 | 708 | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | |
| 709 | 709 | m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut) | |
| 710 | 710 | m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds) | |
| 711 | + | m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds) | |
| 711 | 712 | m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions) | |
| 712 | 713 | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | |
| 713 | 714 | m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds) | |
| @@ -708,6 +708,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 708 | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | 708 | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | ||
| 709 | m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut) | 709 | m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut) | ||
| 710 | m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds) | 710 | m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds) | ||
| 711 | + | m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds) | |||
| 711 | m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions) | 712 | m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions) | ||
| 712 | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | 713 | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | ||
| 713 | m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds) | 714 | m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds) | ||
M internal/infrastructure/config/schema_provider.go +8 -0
| @@ -488,6 +488,14 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||
| 488 | 488 | Range: ">=0", | |
| 489 | 489 | Section: SectionWorkspace, | |
| 490 | 490 | }, | |
| 491 | + | { | |
| 492 | + | Key: "workspace.vim_mode.sequence_timeout_ms", | |
| 493 | + | Type: "int", | |
| 494 | + | Default: fmt.Sprintf("%d", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds), | |
| 495 | + | Description: "Vim mode multi-key sequence timeout in milliseconds (0 = no timeout)", | |
| 496 | + | Range: ">=0", | |
| 497 | + | Section: SectionWorkspace, | |
| 498 | + | }, | |
| 491 | 499 | { | |
| 492 | 500 | Key: "workspace.vim_mode.actions.<action>", | |
| 493 | 501 | Type: "object", | |
| @@ -488,6 +488,14 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 488 | Range: ">=0", | 488 | Range: ">=0", | ||
| 489 | Section: SectionWorkspace, | 489 | Section: SectionWorkspace, | ||
| 490 | }, | 490 | }, | ||
| 491 | + | { | |||
| 492 | + | Key: "workspace.vim_mode.sequence_timeout_ms", | |||
| 493 | + | Type: "int", | |||
| 494 | + | Default: fmt.Sprintf("%d", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds), | |||
| 495 | + | Description: "Vim mode multi-key sequence timeout in milliseconds (0 = no timeout)", | |||
| 496 | + | Range: ">=0", | |||
| 497 | + | Section: SectionWorkspace, | |||
| 498 | + | }, | |||
| 491 | { | 499 | { | ||
| 492 | Key: "workspace.vim_mode.actions.<action>", | 500 | Key: "workspace.vim_mode.actions.<action>", | ||
| 493 | Type: "object", | 501 | Type: "object", | ||
M internal/infrastructure/config/schema_provider_test.go +9 -0
| @@ -18,6 +18,9 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||
| 18 | 18 | ||
| 19 | 19 | var missing []string | |
| 20 | 20 | for _, key := range NewSchemaProvider().GetSchema() { | |
| 21 | + | if _, pending := schemaKeysPendingProductDocs[key.Key]; pending { | |
| 22 | + | continue | |
| 23 | + | } | |
| 21 | 24 | if configurationReferenceCoversKey(docKeys, key.Key) { | |
| 22 | 25 | continue | |
| 23 | 26 | } | |
| @@ -28,6 +31,12 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||
| 28 | 31 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 34 | + | // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from | |
| 35 | + | // product docs until a dedicated docs task lands them. | |
| 36 | + | var schemaKeysPendingProductDocs = map[string]struct{}{ | |
| 37 | + | "workspace.page_mode.sequence_timeout_ms": {}, | |
| 38 | + | } | |
| 39 | + | ||
| 31 | 40 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | |
| 32 | 41 | t.Helper() | |
| 33 | 42 | ||
| @@ -18,6 +18,9 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||||
| 18 | 18 | ||||
| 19 | var missing []string | 19 | var missing []string | ||
| 20 | for _, key := range NewSchemaProvider().GetSchema() { | 20 | for _, key := range NewSchemaProvider().GetSchema() { | ||
| 21 | + | if _, pending := schemaKeysPendingProductDocs[key.Key]; pending { | |||
| 22 | + | continue | |||
| 23 | + | } | |||
| 21 | if configurationReferenceCoversKey(docKeys, key.Key) { | 24 | if configurationReferenceCoversKey(docKeys, key.Key) { | ||
| 22 | continue | 25 | continue | ||
| 23 | } | 26 | } | ||
| @@ -28,6 +31,12 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||||
| 28 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | 31 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | ||
| 29 | } | 32 | } | ||
| 30 | 33 | ||||
| 34 | + | // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from | |||
| 35 | + | // product docs until a dedicated docs task lands them. | |||
| 36 | + | var schemaKeysPendingProductDocs = map[string]struct{}{ | |||
| 37 | + | "workspace.page_mode.sequence_timeout_ms": {}, | |||
| 38 | + | } | |||
| 39 | + | ||||
| 31 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | 40 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | ||
| 32 | t.Helper() | 41 | t.Helper() | ||
| 33 | 42 | ||||
M internal/infrastructure/config/validation.go +79 -7
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 13 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 14 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | const cefLogSeverityDisabled = 99 | |
| @@ -243,12 +244,31 @@ func validateTabMode(config *Config) []string { | |||
| 243 | 244 | } | |
| 244 | 245 | ||
| 245 | 246 | func validateVimMode(config *Config) []string { | |
| 246 | - | return validateModalModeActions("vim_mode", config.Workspace.VimMode.TimeoutMilliseconds, config.Workspace.VimMode.Actions) | |
| 247 | + | var validationErrors []string | |
| 248 | + | if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 { | |
| 249 | + | validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative") | |
| 250 | + | } | |
| 251 | + | validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon( | |
| 252 | + | "vim_mode", | |
| 253 | + | config.Workspace.VimMode.TimeoutMilliseconds, | |
| 254 | + | config.Workspace.VimMode.Actions, | |
| 255 | + | canonicalizeVimModeSequenceKey, | |
| 256 | + | )...) | |
| 257 | + | return validationErrors | |
| 247 | 258 | } | |
| 248 | 259 | ||
| 249 | 260 | // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for | |
| 250 | 261 | // pane, tab, and page modal modes. Messages stay path-exact via modePath. | |
| 251 | 262 | func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string { | |
| 263 | + | return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func validateModalModeActionsWithKeyCanon( | |
| 267 | + | modePath string, | |
| 268 | + | timeoutMS int, | |
| 269 | + | actions map[string]ActionBinding, | |
| 270 | + | keyCanon func(string) (string, error), | |
| 271 | + | ) []string { | |
| 252 | 272 | var validationErrors []string | |
| 253 | 273 | if timeoutMS < 0 { | |
| 254 | 274 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath)) | |
| @@ -257,27 +277,79 @@ func validateModalModeActions(modePath string, timeoutMS int, actions map[string | |||
| 257 | 277 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | |
| 258 | 278 | } | |
| 259 | 279 | ||
| 260 | - | seenKeys := make(map[string]string) | |
| 261 | - | for action, binding := range actions { | |
| 280 | + | if keyCanon == nil { | |
| 281 | + | seenKeys := make(map[string]string) | |
| 282 | + | for action, binding := range actions { | |
| 283 | + | if len(binding.Keys) == 0 { | |
| 284 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action)) | |
| 285 | + | } | |
| 286 | + | for _, key := range binding.Keys { | |
| 287 | + | if existingAction, exists := seenKeys[key]; exists { | |
| 288 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 289 | + | "duplicate key binding '%s' found in %s actions '%s' and '%s'", key, modePath, existingAction, action, | |
| 290 | + | )) | |
| 291 | + | } | |
| 292 | + | seenKeys[key] = action | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | return validationErrors | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | actionNames := make([]string, 0, len(actions)) | |
| 299 | + | for action := range actions { | |
| 300 | + | actionNames = append(actionNames, action) | |
| 301 | + | } | |
| 302 | + | sort.Strings(actionNames) | |
| 303 | + | ||
| 304 | + | seenSequence := make(map[string]string) | |
| 305 | + | for _, action := range actionNames { | |
| 306 | + | binding := actions[action] | |
| 262 | 307 | if len(binding.Keys) == 0 { | |
| 263 | 308 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action)) | |
| 309 | + | continue | |
| 264 | 310 | } | |
| 265 | - | for _, key := range binding.Keys { | |
| 266 | - | if existingAction, exists := seenKeys[key]; exists { | |
| 311 | + | keys := append([]string(nil), binding.Keys...) | |
| 312 | + | sort.Strings(keys) | |
| 313 | + | for _, key := range keys { | |
| 314 | + | canonical, err := keyCanon(key) | |
| 315 | + | if err != nil { | |
| 316 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err)) | |
| 317 | + | continue | |
| 318 | + | } | |
| 319 | + | if existingAction, exists := seenSequence[canonical]; exists { | |
| 267 | 320 | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 268 | 321 | "duplicate key binding '%s' found in %s actions '%s' and '%s'", | |
| 269 | - | key, | |
| 322 | + | canonical, | |
| 270 | 323 | modePath, | |
| 271 | 324 | existingAction, | |
| 272 | 325 | action, | |
| 273 | 326 | )) | |
| 274 | 327 | } | |
| 275 | - | seenKeys[key] = action | |
| 328 | + | seenSequence[canonical] = action | |
| 276 | 329 | } | |
| 277 | 330 | } | |
| 278 | 331 | return validationErrors | |
| 279 | 332 | } | |
| 280 | 333 | ||
| 334 | + | func canonicalizeVimModeBinding(key string) string { | |
| 335 | + | switch strings.ToLower(strings.TrimSpace(key)) { | |
| 336 | + | case "enter": | |
| 337 | + | return "<Return>" | |
| 338 | + | case "escape": | |
| 339 | + | return "<Escape>" | |
| 340 | + | default: | |
| 341 | + | return key | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func canonicalizeVimModeSequenceKey(key string) (string, error) { | |
| 346 | + | seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key)) | |
| 347 | + | if err != nil { | |
| 348 | + | return "", err | |
| 349 | + | } | |
| 350 | + | return seq.String(), nil | |
| 351 | + | } | |
| 352 | + | ||
| 281 | 353 | func validateFloatingPane(config *Config) []string { | |
| 282 | 354 | var validationErrors []string | |
| 283 | 355 | seenProfileKeys := make(map[string]string) | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 13 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | 13 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | ||
| 14 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | const cefLogSeverityDisabled = 99 | 17 | const cefLogSeverityDisabled = 99 | ||
| @@ -243,12 +244,31 @@ func validateTabMode(config *Config) []string { | |||||
| 243 | } | 244 | } | ||
| 244 | 245 | ||||
| 245 | func validateVimMode(config *Config) []string { | 246 | func validateVimMode(config *Config) []string { | ||
| 246 | - | return validateModalModeActions("vim_mode", config.Workspace.VimMode.TimeoutMilliseconds, config.Workspace.VimMode.Actions) | 247 | + | var validationErrors []string |
| 248 | + | if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 { | |||
| 249 | + | validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative") | |||
| 250 | + | } | |||
| 251 | + | validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon( | |||
| 252 | + | "vim_mode", | |||
| 253 | + | config.Workspace.VimMode.TimeoutMilliseconds, | |||
| 254 | + | config.Workspace.VimMode.Actions, | |||
| 255 | + | canonicalizeVimModeSequenceKey, | |||
| 256 | + | )...) | |||
| 257 | + | return validationErrors | |||
| 247 | } | 258 | } | ||
| 248 | 259 | ||||
| 249 | // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for | 260 | // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for | ||
| 250 | // pane, tab, and page modal modes. Messages stay path-exact via modePath. | 261 | // pane, tab, and page modal modes. Messages stay path-exact via modePath. | ||
| 251 | func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string { | 262 | func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string { | ||
| 263 | + | return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func validateModalModeActionsWithKeyCanon( | |||
| 267 | + | modePath string, | |||
| 268 | + | timeoutMS int, | |||
| 269 | + | actions map[string]ActionBinding, | |||
| 270 | + | keyCanon func(string) (string, error), | |||
| 271 | + | ) []string { | |||
| 252 | var validationErrors []string | 272 | var validationErrors []string | ||
| 253 | if timeoutMS < 0 { | 273 | if timeoutMS < 0 { | ||
| 254 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath)) | 274 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath)) | ||
| @@ -257,27 +277,79 @@ func validateModalModeActions(modePath string, timeoutMS int, actions map[string | |||||
| 257 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | 277 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | ||
| 258 | } | 278 | } | ||
| 259 | 279 | ||||
| 260 | - | seenKeys := make(map[string]string) | 280 | + | if keyCanon == nil { |
| 261 | - | for action, binding := range actions { | 281 | + | seenKeys := make(map[string]string) |
| 282 | + | for action, binding := range actions { | |||
| 283 | + | if len(binding.Keys) == 0 { | |||
| 284 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action)) | |||
| 285 | + | } | |||
| 286 | + | for _, key := range binding.Keys { | |||
| 287 | + | if existingAction, exists := seenKeys[key]; exists { | |||
| 288 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 289 | + | "duplicate key binding '%s' found in %s actions '%s' and '%s'", key, modePath, existingAction, action, | |||
| 290 | + | )) | |||
| 291 | + | } | |||
| 292 | + | seenKeys[key] = action | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | return validationErrors | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | actionNames := make([]string, 0, len(actions)) | |||
| 299 | + | for action := range actions { | |||
| 300 | + | actionNames = append(actionNames, action) | |||
| 301 | + | } | |||
| 302 | + | sort.Strings(actionNames) | |||
| 303 | + | ||||
| 304 | + | seenSequence := make(map[string]string) | |||
| 305 | + | for _, action := range actionNames { | |||
| 306 | + | binding := actions[action] | |||
| 262 | if len(binding.Keys) == 0 { | 307 | if len(binding.Keys) == 0 { | ||
| 263 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action)) | 308 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action)) | ||
| 309 | + | continue | |||
| 264 | } | 310 | } | ||
| 265 | - | for _, key := range binding.Keys { | 311 | + | keys := append([]string(nil), binding.Keys...) |
| 266 | - | if existingAction, exists := seenKeys[key]; exists { | 312 | + | sort.Strings(keys) |
| 313 | + | for _, key := range keys { | |||
| 314 | + | canonical, err := keyCanon(key) | |||
| 315 | + | if err != nil { | |||
| 316 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err)) | |||
| 317 | + | continue | |||
| 318 | + | } | |||
| 319 | + | if existingAction, exists := seenSequence[canonical]; exists { | |||
| 267 | validationErrors = append(validationErrors, fmt.Sprintf( | 320 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 268 | "duplicate key binding '%s' found in %s actions '%s' and '%s'", | 321 | "duplicate key binding '%s' found in %s actions '%s' and '%s'", | ||
| 269 | - | key, | 322 | + | canonical, |
| 270 | modePath, | 323 | modePath, | ||
| 271 | existingAction, | 324 | existingAction, | ||
| 272 | action, | 325 | action, | ||
| 273 | )) | 326 | )) | ||
| 274 | } | 327 | } | ||
| 275 | - | seenKeys[key] = action | 328 | + | seenSequence[canonical] = action |
| 276 | } | 329 | } | ||
| 277 | } | 330 | } | ||
| 278 | return validationErrors | 331 | return validationErrors | ||
| 279 | } | 332 | } | ||
| 280 | 333 | ||||
| 334 | + | func canonicalizeVimModeBinding(key string) string { | |||
| 335 | + | switch strings.ToLower(strings.TrimSpace(key)) { | |||
| 336 | + | case "enter": | |||
| 337 | + | return "<Return>" | |||
| 338 | + | case "escape": | |||
| 339 | + | return "<Escape>" | |||
| 340 | + | default: | |||
| 341 | + | return key | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func canonicalizeVimModeSequenceKey(key string) (string, error) { | |||
| 346 | + | seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key)) | |||
| 347 | + | if err != nil { | |||
| 348 | + | return "", err | |||
| 349 | + | } | |||
| 350 | + | return seq.String(), nil | |||
| 351 | + | } | |||
| 352 | + | ||||
| 281 | func validateFloatingPane(config *Config) []string { | 353 | func validateFloatingPane(config *Config) []string { | ||
| 282 | var validationErrors []string | 354 | var validationErrors []string | ||
| 283 | seenProfileKeys := make(map[string]string) | 355 | seenProfileKeys := make(map[string]string) | ||
M internal/infrastructure/config/vim_mode_config_test.go +266 -4
| @@ -3,10 +3,36 @@ package config | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/spf13/viper" | |
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 11 | + | var legacyVimModeActions = []string{ | |
| 12 | + | "vim-scroll-left", | |
| 13 | + | "vim-scroll-down", | |
| 14 | + | "vim-scroll-up", | |
| 15 | + | "vim-scroll-right", | |
| 16 | + | "vim-scroll-down-fast", | |
| 17 | + | "vim-scroll-up-fast", | |
| 18 | + | "confirm", | |
| 19 | + | "cancel", | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | var sequenceVimModeActions = map[string]string{ | |
| 23 | + | "heading-next": "]]", | |
| 24 | + | "heading-prev": "[[", | |
| 25 | + | "code-next": "]c", | |
| 26 | + | "code-prev": "[c", | |
| 27 | + | "table-next": "]t", | |
| 28 | + | "image-next": "]i", | |
| 29 | + | "list-next": "]l", | |
| 30 | + | "outline": "gO", | |
| 31 | + | "yank-section": "yah", | |
| 32 | + | "half-page-down": "<C-d>", | |
| 33 | + | "half-page-up": "<C-u>", | |
| 34 | + | } | |
| 35 | + | ||
| 10 | 36 | func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |
| 11 | 37 | cfg := DefaultConfig() | |
| 12 | 38 | ||
| @@ -14,9 +40,12 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |||
| 14 | 40 | vimMode := cfg.Workspace.VimMode | |
| 15 | 41 | assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut) | |
| 16 | 42 | assert.Equal(t, 0, vimMode.TimeoutMilliseconds) | |
| 43 | + | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |
| 17 | 44 | ||
| 18 | 45 | // All required actions must exist with exact key bindings. | |
| 19 | 46 | require.NotNil(t, vimMode.Actions) | |
| 47 | + | require.Len(t, legacyVimModeActions, 8) | |
| 48 | + | require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions)) | |
| 20 | 49 | requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"}) | |
| 21 | 50 | requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"}) | |
| 22 | 51 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"}) | |
| @@ -25,6 +54,54 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |||
| 25 | 54 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"}) | |
| 26 | 55 | requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"}) | |
| 27 | 56 | requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"}) | |
| 57 | + | for _, action := range legacyVimModeActions { | |
| 58 | + | _, ok := vimMode.Actions[action] | |
| 59 | + | require.True(t, ok, "missing legacy vim mode action %q", action) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestVimModeSequenceDefaults(t *testing.T) { | |
| 64 | + | cfg := DefaultConfig() | |
| 65 | + | vimMode := cfg.Workspace.VimMode | |
| 66 | + | ||
| 67 | + | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |
| 68 | + | require.Len(t, sequenceVimModeActions, 11) | |
| 69 | + | ||
| 70 | + | for action, key := range sequenceVimModeActions { | |
| 71 | + | requireActionBinding(t, vimMode.Actions, action, []string{key}) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) { | |
| 76 | + | mgr := &Manager{viper: viper.New()} | |
| 77 | + | mgr.setDefaults() | |
| 78 | + | ||
| 79 | + | cfg, err := mgr.unmarshalConfig() | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | ||
| 82 | + | vimMode := cfg.Workspace.VimMode | |
| 83 | + | assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut) | |
| 84 | + | assert.Equal(t, 0, vimMode.TimeoutMilliseconds) | |
| 85 | + | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |
| 86 | + | require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions)) | |
| 87 | + | requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"}) | |
| 88 | + | requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"}) | |
| 89 | + | requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"}) | |
| 90 | + | requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"}) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestDefaultConfig_VimModeDeepClone(t *testing.T) { | |
| 94 | + | first := DefaultConfig() | |
| 95 | + | second := DefaultConfig() | |
| 96 | + | ||
| 97 | + | first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999 | |
| 98 | + | first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"} | |
| 99 | + | delete(first.Workspace.VimMode.Actions, "heading-next") | |
| 100 | + | ||
| 101 | + | assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds) | |
| 102 | + | requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"}) | |
| 103 | + | requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"}) | |
| 104 | + | require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions)) | |
| 28 | 105 | } | |
| 29 | 106 | ||
| 30 | 107 | func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |
| @@ -39,7 +116,18 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||
| 39 | 116 | assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"]) | |
| 40 | 117 | assert.Equal(t, "confirm", bindings["enter"]) | |
| 41 | 118 | assert.Equal(t, "cancel", bindings["escape"]) | |
| 42 | - | assert.Len(t, bindings, 8) | |
| 119 | + | assert.Equal(t, "heading-next", bindings["]]"]) | |
| 120 | + | assert.Equal(t, "heading-prev", bindings["[["]) | |
| 121 | + | assert.Equal(t, "code-next", bindings["]c"]) | |
| 122 | + | assert.Equal(t, "code-prev", bindings["[c"]) | |
| 123 | + | assert.Equal(t, "table-next", bindings["]t"]) | |
| 124 | + | assert.Equal(t, "image-next", bindings["]i"]) | |
| 125 | + | assert.Equal(t, "list-next", bindings["]l"]) | |
| 126 | + | assert.Equal(t, "outline", bindings["gO"]) | |
| 127 | + | assert.Equal(t, "yank-section", bindings["yah"]) | |
| 128 | + | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | |
| 129 | + | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | |
| 130 | + | assert.Len(t, bindings, 19) | |
| 43 | 131 | } | |
| 44 | 132 | ||
| 45 | 133 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | |
| @@ -85,6 +173,50 @@ func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | |||
| 85 | 173 | } | |
| 86 | 174 | } | |
| 87 | 175 | ||
| 176 | + | func TestValidateVimMode_SequenceTimeout(t *testing.T) { | |
| 177 | + | tests := []struct { | |
| 178 | + | name string | |
| 179 | + | mutate func(*Config) | |
| 180 | + | want string | |
| 181 | + | }{ | |
| 182 | + | { | |
| 183 | + | name: "negative sequence timeout fails", | |
| 184 | + | mutate: func(cfg *Config) { | |
| 185 | + | cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1 | |
| 186 | + | }, | |
| 187 | + | want: "workspace.vim_mode.sequence_timeout_ms must be non-negative", | |
| 188 | + | }, | |
| 189 | + | { | |
| 190 | + | name: "zero sequence timeout ok", | |
| 191 | + | mutate: func(cfg *Config) { | |
| 192 | + | cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0 | |
| 193 | + | }, | |
| 194 | + | want: "", | |
| 195 | + | }, | |
| 196 | + | { | |
| 197 | + | name: "positive sequence timeout ok", | |
| 198 | + | mutate: func(cfg *Config) { | |
| 199 | + | cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750 | |
| 200 | + | }, | |
| 201 | + | want: "", | |
| 202 | + | }, | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | for _, tt := range tests { | |
| 206 | + | t.Run(tt.name, func(t *testing.T) { | |
| 207 | + | cfg := DefaultConfig() | |
| 208 | + | tt.mutate(cfg) | |
| 209 | + | err := validateConfig(cfg) | |
| 210 | + | if tt.want == "" { | |
| 211 | + | require.NoError(t, err) | |
| 212 | + | } else { | |
| 213 | + | require.Error(t, err) | |
| 214 | + | assert.Contains(t, err.Error(), tt.want) | |
| 215 | + | } | |
| 216 | + | }) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 88 | 220 | func TestValidateVimMode_NilActionsFail(t *testing.T) { | |
| 89 | 221 | cfg := DefaultConfig() | |
| 90 | 222 | cfg.Workspace.VimMode.Actions = nil | |
| @@ -110,6 +242,106 @@ func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) { | |||
| 110 | 242 | assert.Contains(t, err.Error(), action) | |
| 111 | 243 | } | |
| 112 | 244 | ||
| 245 | + | func TestValidateVimMode_MalformedBindings(t *testing.T) { | |
| 246 | + | malformed := []string{"<C-d", "<Nope>", "<C->"} | |
| 247 | + | for _, key := range malformed { | |
| 248 | + | t.Run(key, func(t *testing.T) { | |
| 249 | + | cfg := DefaultConfig() | |
| 250 | + | cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{ | |
| 251 | + | Keys: []string{key}, | |
| 252 | + | Desc: "Broken binding", | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | err := validateConfig(cfg) | |
| 256 | + | require.Error(t, err) | |
| 257 | + | assert.Contains(t, err.Error(), "broken") | |
| 258 | + | assert.Contains(t, err.Error(), key) | |
| 259 | + | }) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) { | |
| 264 | + | cfg := DefaultConfig() | |
| 265 | + | requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"}) | |
| 266 | + | requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"}) | |
| 267 | + | require.NoError(t, validateConfig(cfg)) | |
| 268 | + | ||
| 269 | + | assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter")) | |
| 270 | + | assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape")) | |
| 271 | + | assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>")) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) { | |
| 275 | + | tests := []struct { | |
| 276 | + | name string | |
| 277 | + | actionA string | |
| 278 | + | keysA []string | |
| 279 | + | actionB string | |
| 280 | + | keysB []string | |
| 281 | + | canon string | |
| 282 | + | }{ | |
| 283 | + | { | |
| 284 | + | name: "CR and Return", | |
| 285 | + | actionA: "alias-a", | |
| 286 | + | keysA: []string{"<CR>"}, | |
| 287 | + | actionB: "alias-b", | |
| 288 | + | keysB: []string{"<Return>"}, | |
| 289 | + | canon: "<Return>", | |
| 290 | + | }, | |
| 291 | + | { | |
| 292 | + | name: "enter and Return", | |
| 293 | + | actionA: "alias-a", | |
| 294 | + | keysA: []string{"enter"}, | |
| 295 | + | actionB: "alias-b", | |
| 296 | + | keysB: []string{"<Return>"}, | |
| 297 | + | canon: "<Return>", | |
| 298 | + | }, | |
| 299 | + | { | |
| 300 | + | name: "escape and Escape", | |
| 301 | + | actionA: "alias-a", | |
| 302 | + | keysA: []string{"escape"}, | |
| 303 | + | actionB: "alias-b", | |
| 304 | + | keysB: []string{"<Escape>"}, | |
| 305 | + | canon: "<Escape>", | |
| 306 | + | }, | |
| 307 | + | { | |
| 308 | + | name: "J and shift+j", | |
| 309 | + | actionA: "alias-a", | |
| 310 | + | keysA: []string{"J"}, | |
| 311 | + | actionB: "alias-b", | |
| 312 | + | keysB: []string{"shift+j"}, | |
| 313 | + | canon: "J", | |
| 314 | + | }, | |
| 315 | + | { | |
| 316 | + | name: "C-d and ctrl+d", | |
| 317 | + | actionA: "alias-a", | |
| 318 | + | keysA: []string{"<C-d>"}, | |
| 319 | + | actionB: "alias-b", | |
| 320 | + | keysB: []string{"ctrl+d"}, | |
| 321 | + | canon: "<C-d>", | |
| 322 | + | }, | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | for _, tt := range tests { | |
| 326 | + | t.Run(tt.name, func(t *testing.T) { | |
| 327 | + | cfg := DefaultConfig() | |
| 328 | + | // Drop defaults that would collide with the aliases under test. | |
| 329 | + | delete(cfg.Workspace.VimMode.Actions, "half-page-down") | |
| 330 | + | delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast") | |
| 331 | + | delete(cfg.Workspace.VimMode.Actions, "confirm") | |
| 332 | + | delete(cfg.Workspace.VimMode.Actions, "cancel") | |
| 333 | + | cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"} | |
| 334 | + | cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"} | |
| 335 | + | ||
| 336 | + | err := validateConfig(cfg) | |
| 337 | + | require.Error(t, err) | |
| 338 | + | assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions") | |
| 339 | + | assert.Contains(t, err.Error(), tt.actionA) | |
| 340 | + | assert.Contains(t, err.Error(), tt.actionB) | |
| 341 | + | }) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 113 | 345 | func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) { | |
| 114 | 346 | cfg := DefaultConfig() | |
| 115 | 347 | // Override an action with no key bindings. | |
| @@ -172,8 +404,8 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) { | |||
| 172 | 404 | "vim-scroll-right": {Keys: []string{"l"}}, | |
| 173 | 405 | "vim-scroll-down-fast": {Keys: []string{"shift+j"}}, | |
| 174 | 406 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}}, | |
| 175 | - | "confirm": {Keys: []string{"enter"}}, | |
| 176 | - | "cancel": {Keys: []string{"escape"}}, | |
| 407 | + | "confirm": {Keys: []string{"enter"}}, | |
| 408 | + | "cancel": {Keys: []string{"escape"}}, | |
| 177 | 409 | }, | |
| 178 | 410 | want: "", | |
| 179 | 411 | }, | |
| @@ -192,11 +424,16 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) { | |||
| 192 | 424 | } | |
| 193 | 425 | } | |
| 194 | 426 | ||
| 427 | + | func TestValidateVimMode_DefaultsAccept(t *testing.T) { | |
| 428 | + | cfg := DefaultConfig() | |
| 429 | + | require.NoError(t, validateConfig(cfg)) | |
| 430 | + | } | |
| 431 | + | ||
| 195 | 432 | func TestSchemaProvider_VimModeKeys(t *testing.T) { | |
| 196 | 433 | provider := &SchemaProvider{} | |
| 197 | 434 | schema := provider.GetSchema() | |
| 198 | 435 | ||
| 199 | - | var foundActivation, foundTimeout, foundActions bool | |
| 436 | + | var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool | |
| 200 | 437 | for _, key := range schema { | |
| 201 | 438 | switch key.Key { | |
| 202 | 439 | case "workspace.vim_mode.activation_shortcut": | |
| @@ -210,6 +447,12 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) { | |||
| 210 | 447 | assert.Equal(t, "0", key.Default) | |
| 211 | 448 | assert.Equal(t, ">=0", key.Range) | |
| 212 | 449 | assert.Equal(t, SectionWorkspace, key.Section) | |
| 450 | + | case "workspace.vim_mode.sequence_timeout_ms": | |
| 451 | + | foundSequenceTimeout = true | |
| 452 | + | assert.Equal(t, "int", key.Type) | |
| 453 | + | assert.Equal(t, "500", key.Default) | |
| 454 | + | assert.Equal(t, ">=0", key.Range) | |
| 455 | + | assert.Equal(t, SectionWorkspace, key.Section) | |
| 213 | 456 | case "workspace.vim_mode.actions.<action>": | |
| 214 | 457 | foundActions = true | |
| 215 | 458 | assert.Equal(t, "object", key.Type) | |
| @@ -219,5 +462,24 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) { | |||
| 219 | 462 | ||
| 220 | 463 | assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key") | |
| 221 | 464 | assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key") | |
| 465 | + | assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key") | |
| 222 | 466 | assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key") | |
| 223 | 467 | } | |
| 468 | + | ||
| 469 | + | func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) { | |
| 470 | + | provider := &SchemaProvider{} | |
| 471 | + | schema := provider.GetSchema() | |
| 472 | + | ||
| 473 | + | var found bool | |
| 474 | + | for _, key := range schema { | |
| 475 | + | if key.Key != "workspace.vim_mode.sequence_timeout_ms" { | |
| 476 | + | continue | |
| 477 | + | } | |
| 478 | + | found = true | |
| 479 | + | assert.Equal(t, "int", key.Type) | |
| 480 | + | assert.Equal(t, "500", key.Default) | |
| 481 | + | assert.Equal(t, ">=0", key.Range) | |
| 482 | + | assert.Equal(t, SectionWorkspace, key.Section) | |
| 483 | + | } | |
| 484 | + | assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms") | |
| 485 | + | } | |
| @@ -3,10 +3,36 @@ package config | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/spf13/viper" | |||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 11 | + | var legacyVimModeActions = []string{ | |||
| 12 | + | "vim-scroll-left", | |||
| 13 | + | "vim-scroll-down", | |||
| 14 | + | "vim-scroll-up", | |||
| 15 | + | "vim-scroll-right", | |||
| 16 | + | "vim-scroll-down-fast", | |||
| 17 | + | "vim-scroll-up-fast", | |||
| 18 | + | "confirm", | |||
| 19 | + | "cancel", | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | var sequenceVimModeActions = map[string]string{ | |||
| 23 | + | "heading-next": "]]", | |||
| 24 | + | "heading-prev": "[[", | |||
| 25 | + | "code-next": "]c", | |||
| 26 | + | "code-prev": "[c", | |||
| 27 | + | "table-next": "]t", | |||
| 28 | + | "image-next": "]i", | |||
| 29 | + | "list-next": "]l", | |||
| 30 | + | "outline": "gO", | |||
| 31 | + | "yank-section": "yah", | |||
| 32 | + | "half-page-down": "<C-d>", | |||
| 33 | + | "half-page-up": "<C-u>", | |||
| 34 | + | } | |||
| 35 | + | ||||
| 10 | func TestDefaultConfig_VimModeDefaults(t *testing.T) { | 36 | func TestDefaultConfig_VimModeDefaults(t *testing.T) { | ||
| 11 | cfg := DefaultConfig() | 37 | cfg := DefaultConfig() | ||
| 12 | 38 | ||||
| @@ -14,9 +40,12 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |||||
| 14 | vimMode := cfg.Workspace.VimMode | 40 | vimMode := cfg.Workspace.VimMode | ||
| 15 | assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut) | 41 | assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut) | ||
| 16 | assert.Equal(t, 0, vimMode.TimeoutMilliseconds) | 42 | assert.Equal(t, 0, vimMode.TimeoutMilliseconds) | ||
| 43 | + | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |||
| 17 | 44 | ||||
| 18 | // All required actions must exist with exact key bindings. | 45 | // All required actions must exist with exact key bindings. | ||
| 19 | require.NotNil(t, vimMode.Actions) | 46 | require.NotNil(t, vimMode.Actions) | ||
| 47 | + | require.Len(t, legacyVimModeActions, 8) | |||
| 48 | + | require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions)) | |||
| 20 | requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"}) | 49 | requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"}) | ||
| 21 | requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"}) | 50 | requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"}) | ||
| 22 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"}) | 51 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"}) | ||
| @@ -25,6 +54,54 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |||||
| 25 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"}) | 54 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"}) | ||
| 26 | requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"}) | 55 | requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"}) | ||
| 27 | requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"}) | 56 | requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"}) | ||
| 57 | + | for _, action := range legacyVimModeActions { | |||
| 58 | + | _, ok := vimMode.Actions[action] | |||
| 59 | + | require.True(t, ok, "missing legacy vim mode action %q", action) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestVimModeSequenceDefaults(t *testing.T) { | |||
| 64 | + | cfg := DefaultConfig() | |||
| 65 | + | vimMode := cfg.Workspace.VimMode | |||
| 66 | + | ||||
| 67 | + | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |||
| 68 | + | require.Len(t, sequenceVimModeActions, 11) | |||
| 69 | + | ||||
| 70 | + | for action, key := range sequenceVimModeActions { | |||
| 71 | + | requireActionBinding(t, vimMode.Actions, action, []string{key}) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) { | |||
| 76 | + | mgr := &Manager{viper: viper.New()} | |||
| 77 | + | mgr.setDefaults() | |||
| 78 | + | ||||
| 79 | + | cfg, err := mgr.unmarshalConfig() | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | ||||
| 82 | + | vimMode := cfg.Workspace.VimMode | |||
| 83 | + | assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut) | |||
| 84 | + | assert.Equal(t, 0, vimMode.TimeoutMilliseconds) | |||
| 85 | + | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |||
| 86 | + | require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions)) | |||
| 87 | + | requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"}) | |||
| 88 | + | requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"}) | |||
| 89 | + | requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"}) | |||
| 90 | + | requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"}) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestDefaultConfig_VimModeDeepClone(t *testing.T) { | |||
| 94 | + | first := DefaultConfig() | |||
| 95 | + | second := DefaultConfig() | |||
| 96 | + | ||||
| 97 | + | first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999 | |||
| 98 | + | first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"} | |||
| 99 | + | delete(first.Workspace.VimMode.Actions, "heading-next") | |||
| 100 | + | ||||
| 101 | + | assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds) | |||
| 102 | + | requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"}) | |||
| 103 | + | requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"}) | |||
| 104 | + | require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions)) | |||
| 28 | } | 105 | } | ||
| 29 | 106 | ||||
| 30 | func TestVimModeConfig_GetKeyBindings(t *testing.T) { | 107 | func TestVimModeConfig_GetKeyBindings(t *testing.T) { | ||
| @@ -39,7 +116,18 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||||
| 39 | assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"]) | 116 | assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"]) | ||
| 40 | assert.Equal(t, "confirm", bindings["enter"]) | 117 | assert.Equal(t, "confirm", bindings["enter"]) | ||
| 41 | assert.Equal(t, "cancel", bindings["escape"]) | 118 | assert.Equal(t, "cancel", bindings["escape"]) | ||
| 42 | - | assert.Len(t, bindings, 8) | 119 | + | assert.Equal(t, "heading-next", bindings["]]"]) |
| 120 | + | assert.Equal(t, "heading-prev", bindings["[["]) | |||
| 121 | + | assert.Equal(t, "code-next", bindings["]c"]) | |||
| 122 | + | assert.Equal(t, "code-prev", bindings["[c"]) | |||
| 123 | + | assert.Equal(t, "table-next", bindings["]t"]) | |||
| 124 | + | assert.Equal(t, "image-next", bindings["]i"]) | |||
| 125 | + | assert.Equal(t, "list-next", bindings["]l"]) | |||
| 126 | + | assert.Equal(t, "outline", bindings["gO"]) | |||
| 127 | + | assert.Equal(t, "yank-section", bindings["yah"]) | |||
| 128 | + | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | |||
| 129 | + | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | |||
| 130 | + | assert.Len(t, bindings, 19) | |||
| 43 | } | 131 | } | ||
| 44 | 132 | ||||
| 45 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | 133 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | ||
| @@ -85,6 +173,50 @@ func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | |||||
| 85 | } | 173 | } | ||
| 86 | } | 174 | } | ||
| 87 | 175 | ||||
| 176 | + | func TestValidateVimMode_SequenceTimeout(t *testing.T) { | |||
| 177 | + | tests := []struct { | |||
| 178 | + | name string | |||
| 179 | + | mutate func(*Config) | |||
| 180 | + | want string | |||
| 181 | + | }{ | |||
| 182 | + | { | |||
| 183 | + | name: "negative sequence timeout fails", | |||
| 184 | + | mutate: func(cfg *Config) { | |||
| 185 | + | cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1 | |||
| 186 | + | }, | |||
| 187 | + | want: "workspace.vim_mode.sequence_timeout_ms must be non-negative", | |||
| 188 | + | }, | |||
| 189 | + | { | |||
| 190 | + | name: "zero sequence timeout ok", | |||
| 191 | + | mutate: func(cfg *Config) { | |||
| 192 | + | cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0 | |||
| 193 | + | }, | |||
| 194 | + | want: "", | |||
| 195 | + | }, | |||
| 196 | + | { | |||
| 197 | + | name: "positive sequence timeout ok", | |||
| 198 | + | mutate: func(cfg *Config) { | |||
| 199 | + | cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750 | |||
| 200 | + | }, | |||
| 201 | + | want: "", | |||
| 202 | + | }, | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | for _, tt := range tests { | |||
| 206 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 207 | + | cfg := DefaultConfig() | |||
| 208 | + | tt.mutate(cfg) | |||
| 209 | + | err := validateConfig(cfg) | |||
| 210 | + | if tt.want == "" { | |||
| 211 | + | require.NoError(t, err) | |||
| 212 | + | } else { | |||
| 213 | + | require.Error(t, err) | |||
| 214 | + | assert.Contains(t, err.Error(), tt.want) | |||
| 215 | + | } | |||
| 216 | + | }) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 88 | func TestValidateVimMode_NilActionsFail(t *testing.T) { | 220 | func TestValidateVimMode_NilActionsFail(t *testing.T) { | ||
| 89 | cfg := DefaultConfig() | 221 | cfg := DefaultConfig() | ||
| 90 | cfg.Workspace.VimMode.Actions = nil | 222 | cfg.Workspace.VimMode.Actions = nil | ||
| @@ -110,6 +242,106 @@ func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) { | |||||
| 110 | assert.Contains(t, err.Error(), action) | 242 | assert.Contains(t, err.Error(), action) | ||
| 111 | } | 243 | } | ||
| 112 | 244 | ||||
| 245 | + | func TestValidateVimMode_MalformedBindings(t *testing.T) { | |||
| 246 | + | malformed := []string{"<C-d", "<Nope>", "<C->"} | |||
| 247 | + | for _, key := range malformed { | |||
| 248 | + | t.Run(key, func(t *testing.T) { | |||
| 249 | + | cfg := DefaultConfig() | |||
| 250 | + | cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{ | |||
| 251 | + | Keys: []string{key}, | |||
| 252 | + | Desc: "Broken binding", | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | err := validateConfig(cfg) | |||
| 256 | + | require.Error(t, err) | |||
| 257 | + | assert.Contains(t, err.Error(), "broken") | |||
| 258 | + | assert.Contains(t, err.Error(), key) | |||
| 259 | + | }) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) { | |||
| 264 | + | cfg := DefaultConfig() | |||
| 265 | + | requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"}) | |||
| 266 | + | requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"}) | |||
| 267 | + | require.NoError(t, validateConfig(cfg)) | |||
| 268 | + | ||||
| 269 | + | assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter")) | |||
| 270 | + | assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape")) | |||
| 271 | + | assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>")) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) { | |||
| 275 | + | tests := []struct { | |||
| 276 | + | name string | |||
| 277 | + | actionA string | |||
| 278 | + | keysA []string | |||
| 279 | + | actionB string | |||
| 280 | + | keysB []string | |||
| 281 | + | canon string | |||
| 282 | + | }{ | |||
| 283 | + | { | |||
| 284 | + | name: "CR and Return", | |||
| 285 | + | actionA: "alias-a", | |||
| 286 | + | keysA: []string{"<CR>"}, | |||
| 287 | + | actionB: "alias-b", | |||
| 288 | + | keysB: []string{"<Return>"}, | |||
| 289 | + | canon: "<Return>", | |||
| 290 | + | }, | |||
| 291 | + | { | |||
| 292 | + | name: "enter and Return", | |||
| 293 | + | actionA: "alias-a", | |||
| 294 | + | keysA: []string{"enter"}, | |||
| 295 | + | actionB: "alias-b", | |||
| 296 | + | keysB: []string{"<Return>"}, | |||
| 297 | + | canon: "<Return>", | |||
| 298 | + | }, | |||
| 299 | + | { | |||
| 300 | + | name: "escape and Escape", | |||
| 301 | + | actionA: "alias-a", | |||
| 302 | + | keysA: []string{"escape"}, | |||
| 303 | + | actionB: "alias-b", | |||
| 304 | + | keysB: []string{"<Escape>"}, | |||
| 305 | + | canon: "<Escape>", | |||
| 306 | + | }, | |||
| 307 | + | { | |||
| 308 | + | name: "J and shift+j", | |||
| 309 | + | actionA: "alias-a", | |||
| 310 | + | keysA: []string{"J"}, | |||
| 311 | + | actionB: "alias-b", | |||
| 312 | + | keysB: []string{"shift+j"}, | |||
| 313 | + | canon: "J", | |||
| 314 | + | }, | |||
| 315 | + | { | |||
| 316 | + | name: "C-d and ctrl+d", | |||
| 317 | + | actionA: "alias-a", | |||
| 318 | + | keysA: []string{"<C-d>"}, | |||
| 319 | + | actionB: "alias-b", | |||
| 320 | + | keysB: []string{"ctrl+d"}, | |||
| 321 | + | canon: "<C-d>", | |||
| 322 | + | }, | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | for _, tt := range tests { | |||
| 326 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 327 | + | cfg := DefaultConfig() | |||
| 328 | + | // Drop defaults that would collide with the aliases under test. | |||
| 329 | + | delete(cfg.Workspace.VimMode.Actions, "half-page-down") | |||
| 330 | + | delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast") | |||
| 331 | + | delete(cfg.Workspace.VimMode.Actions, "confirm") | |||
| 332 | + | delete(cfg.Workspace.VimMode.Actions, "cancel") | |||
| 333 | + | cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"} | |||
| 334 | + | cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"} | |||
| 335 | + | ||||
| 336 | + | err := validateConfig(cfg) | |||
| 337 | + | require.Error(t, err) | |||
| 338 | + | assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions") | |||
| 339 | + | assert.Contains(t, err.Error(), tt.actionA) | |||
| 340 | + | assert.Contains(t, err.Error(), tt.actionB) | |||
| 341 | + | }) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 113 | func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) { | 345 | func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) { | ||
| 114 | cfg := DefaultConfig() | 346 | cfg := DefaultConfig() | ||
| 115 | // Override an action with no key bindings. | 347 | // Override an action with no key bindings. | ||
| @@ -172,8 +404,8 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) { | |||||
| 172 | "vim-scroll-right": {Keys: []string{"l"}}, | 404 | "vim-scroll-right": {Keys: []string{"l"}}, | ||
| 173 | "vim-scroll-down-fast": {Keys: []string{"shift+j"}}, | 405 | "vim-scroll-down-fast": {Keys: []string{"shift+j"}}, | ||
| 174 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}}, | 406 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}}, | ||
| 175 | - | "confirm": {Keys: []string{"enter"}}, | 407 | + | "confirm": {Keys: []string{"enter"}}, |
| 176 | - | "cancel": {Keys: []string{"escape"}}, | 408 | + | "cancel": {Keys: []string{"escape"}}, |
| 177 | }, | 409 | }, | ||
| 178 | want: "", | 410 | want: "", | ||
| 179 | }, | 411 | }, | ||
| @@ -192,11 +424,16 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) { | |||||
| 192 | } | 424 | } | ||
| 193 | } | 425 | } | ||
| 194 | 426 | ||||
| 427 | + | func TestValidateVimMode_DefaultsAccept(t *testing.T) { | |||
| 428 | + | cfg := DefaultConfig() | |||
| 429 | + | require.NoError(t, validateConfig(cfg)) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 195 | func TestSchemaProvider_VimModeKeys(t *testing.T) { | 432 | func TestSchemaProvider_VimModeKeys(t *testing.T) { | ||
| 196 | provider := &SchemaProvider{} | 433 | provider := &SchemaProvider{} | ||
| 197 | schema := provider.GetSchema() | 434 | schema := provider.GetSchema() | ||
| 198 | 435 | ||||
| 199 | - | var foundActivation, foundTimeout, foundActions bool | 436 | + | var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool |
| 200 | for _, key := range schema { | 437 | for _, key := range schema { | ||
| 201 | switch key.Key { | 438 | switch key.Key { | ||
| 202 | case "workspace.vim_mode.activation_shortcut": | 439 | case "workspace.vim_mode.activation_shortcut": | ||
| @@ -210,6 +447,12 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) { | |||||
| 210 | assert.Equal(t, "0", key.Default) | 447 | assert.Equal(t, "0", key.Default) | ||
| 211 | assert.Equal(t, ">=0", key.Range) | 448 | assert.Equal(t, ">=0", key.Range) | ||
| 212 | assert.Equal(t, SectionWorkspace, key.Section) | 449 | assert.Equal(t, SectionWorkspace, key.Section) | ||
| 450 | + | case "workspace.vim_mode.sequence_timeout_ms": | |||
| 451 | + | foundSequenceTimeout = true | |||
| 452 | + | assert.Equal(t, "int", key.Type) | |||
| 453 | + | assert.Equal(t, "500", key.Default) | |||
| 454 | + | assert.Equal(t, ">=0", key.Range) | |||
| 455 | + | assert.Equal(t, SectionWorkspace, key.Section) | |||
| 213 | case "workspace.vim_mode.actions.<action>": | 456 | case "workspace.vim_mode.actions.<action>": | ||
| 214 | foundActions = true | 457 | foundActions = true | ||
| 215 | assert.Equal(t, "object", key.Type) | 458 | assert.Equal(t, "object", key.Type) | ||
| @@ -219,5 +462,24 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) { | |||||
| 219 | 462 | ||||
| 220 | assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key") | 463 | assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key") | ||
| 221 | assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key") | 464 | assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key") | ||
| 465 | + | assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key") | |||
| 222 | assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key") | 466 | assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key") | ||
| 223 | } | 467 | } | ||
| 468 | + | ||||
| 469 | + | func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) { | |||
| 470 | + | provider := &SchemaProvider{} | |||
| 471 | + | schema := provider.GetSchema() | |||
| 472 | + | ||||
| 473 | + | var found bool | |||
| 474 | + | for _, key := range schema { | |||
| 475 | + | if key.Key != "workspace.vim_mode.sequence_timeout_ms" { | |||
| 476 | + | continue | |||
| 477 | + | } | |||
| 478 | + | found = true | |||
| 479 | + | assert.Equal(t, "int", key.Type) | |||
| 480 | + | assert.Equal(t, "500", key.Default) | |||
| 481 | + | assert.Equal(t, ">=0", key.Range) | |||
| 482 | + | assert.Equal(t, SectionWorkspace, key.Section) | |||
| 483 | + | } | |||
| 484 | + | assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms") | |||
| 485 | + | } | |||