wip: legacy config migration and keybinding modal fix
- Add ConfigTransformer port interface for legacy config transformation - Add TransformLegacyConfigUseCase for clean architecture - Add LegacyConfigTransformer implementation with tests - Integrate transformer in loader.go and migrate.go - Fix alert-dialog-action/cancel to properly pass onclick and children - Add debug logging to trace message routing issues
- commit
- a2e8a81935df2c5450b2b3dbcb60dbc166e885ab
- author
- bnema · · 2026-01-11 17:37 UTC
- committer
- brice · · 2026-01-24 06:23 UTC
- parent
- 64e8895
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +504 -9
M .mockery.yaml +1 -0
| @@ -10,6 +10,7 @@ packages: | |||
| 10 | 10 | interfaces: | |
| 11 | 11 | ConfigMigrator: {} | |
| 12 | 12 | ConfigSchemaProvider: {} | |
| 13 | + | ConfigTransformer: {} | |
| 13 | 14 | DatabaseProvider: {} | |
| 14 | 15 | DiffFormatter: {} | |
| 15 | 16 | SessionLogger: {} | |
| @@ -10,6 +10,7 @@ packages: | |||||
| 10 | interfaces: | 10 | interfaces: | ||
| 11 | ConfigMigrator: {} | 11 | ConfigMigrator: {} | ||
| 12 | ConfigSchemaProvider: {} | 12 | ConfigSchemaProvider: {} | ||
| 13 | + | ConfigTransformer: {} | |||
| 13 | DatabaseProvider: {} | 14 | DatabaseProvider: {} | ||
| 14 | DiffFormatter: {} | 15 | DiffFormatter: {} | ||
| 15 | SessionLogger: {} | 16 | SessionLogger: {} | ||
A internal/application/port/config_transformer.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | // ConfigTransformer transforms legacy config formats to current format. | |
| 4 | + | type ConfigTransformer interface { | |
| 5 | + | // TransformLegacyActions converts old-format action bindings (slices) | |
| 6 | + | // to new ActionBinding format (map with keys and desc). | |
| 7 | + | // Modifies the rawConfig map in place. | |
| 8 | + | TransformLegacyActions(rawConfig map[string]any) | |
| 9 | + | } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | // ConfigTransformer transforms legacy config formats to current format. | |||
| 4 | + | type ConfigTransformer interface { | |||
| 5 | + | // TransformLegacyActions converts old-format action bindings (slices) | |||
| 6 | + | // to new ActionBinding format (map with keys and desc). | |||
| 7 | + | // Modifies the rawConfig map in place. | |||
| 8 | + | TransformLegacyActions(rawConfig map[string]any) | |||
| 9 | + | } | |||
M internal/application/port/mocks/mocks.go +67 -0
| @@ -1037,6 +1037,73 @@ func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []ent | |||
| 1037 | 1037 | return _c | |
| 1038 | 1038 | } | |
| 1039 | 1039 | ||
| 1040 | + | // NewMockConfigTransformer creates a new instance of MockConfigTransformer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1041 | + | // The first argument is typically a *testing.T value. | |
| 1042 | + | func NewMockConfigTransformer(t interface { | |
| 1043 | + | mock.TestingT | |
| 1044 | + | Cleanup(func()) | |
| 1045 | + | }) *MockConfigTransformer { | |
| 1046 | + | mock := &MockConfigTransformer{} | |
| 1047 | + | mock.Mock.Test(t) | |
| 1048 | + | ||
| 1049 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1050 | + | ||
| 1051 | + | return mock | |
| 1052 | + | } | |
| 1053 | + | ||
| 1054 | + | // MockConfigTransformer is an autogenerated mock type for the ConfigTransformer type | |
| 1055 | + | type MockConfigTransformer struct { | |
| 1056 | + | mock.Mock | |
| 1057 | + | } | |
| 1058 | + | ||
| 1059 | + | type MockConfigTransformer_Expecter struct { | |
| 1060 | + | mock *mock.Mock | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | func (_m *MockConfigTransformer) EXPECT() *MockConfigTransformer_Expecter { | |
| 1064 | + | return &MockConfigTransformer_Expecter{mock: &_m.Mock} | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | // TransformLegacyActions provides a mock function for the type MockConfigTransformer | |
| 1068 | + | func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |
| 1069 | + | _mock.Called(rawConfig) | |
| 1070 | + | return | |
| 1071 | + | } | |
| 1072 | + | ||
| 1073 | + | // MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions' | |
| 1074 | + | type MockConfigTransformer_TransformLegacyActions_Call struct { | |
| 1075 | + | *mock.Call | |
| 1076 | + | } | |
| 1077 | + | ||
| 1078 | + | // TransformLegacyActions is a helper method to define mock.On call | |
| 1079 | + | // - rawConfig map[string]any | |
| 1080 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1081 | + | return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)} | |
| 1082 | + | } | |
| 1083 | + | ||
| 1084 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1085 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1086 | + | var arg0 map[string]any | |
| 1087 | + | if args[0] != nil { | |
| 1088 | + | arg0 = args[0].(map[string]any) | |
| 1089 | + | } | |
| 1090 | + | run( | |
| 1091 | + | arg0, | |
| 1092 | + | ) | |
| 1093 | + | }) | |
| 1094 | + | return _c | |
| 1095 | + | } | |
| 1096 | + | ||
| 1097 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1098 | + | _c.Call.Return() | |
| 1099 | + | return _c | |
| 1100 | + | } | |
| 1101 | + | ||
| 1102 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1103 | + | _c.Run(run) | |
| 1104 | + | return _c | |
| 1105 | + | } | |
| 1106 | + | ||
| 1040 | 1107 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1041 | 1108 | // The first argument is typically a *testing.T value. | |
| 1042 | 1109 | func NewMockDatabaseProvider(t interface { | |
| @@ -1037,6 +1037,73 @@ func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []ent | |||||
| 1037 | return _c | 1037 | return _c | ||
| 1038 | } | 1038 | } | ||
| 1039 | 1039 | ||||
| 1040 | + | // NewMockConfigTransformer creates a new instance of MockConfigTransformer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1041 | + | // The first argument is typically a *testing.T value. | |||
| 1042 | + | func NewMockConfigTransformer(t interface { | |||
| 1043 | + | mock.TestingT | |||
| 1044 | + | Cleanup(func()) | |||
| 1045 | + | }) *MockConfigTransformer { | |||
| 1046 | + | mock := &MockConfigTransformer{} | |||
| 1047 | + | mock.Mock.Test(t) | |||
| 1048 | + | ||||
| 1049 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1050 | + | ||||
| 1051 | + | return mock | |||
| 1052 | + | } | |||
| 1053 | + | ||||
| 1054 | + | // MockConfigTransformer is an autogenerated mock type for the ConfigTransformer type | |||
| 1055 | + | type MockConfigTransformer struct { | |||
| 1056 | + | mock.Mock | |||
| 1057 | + | } | |||
| 1058 | + | ||||
| 1059 | + | type MockConfigTransformer_Expecter struct { | |||
| 1060 | + | mock *mock.Mock | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | func (_m *MockConfigTransformer) EXPECT() *MockConfigTransformer_Expecter { | |||
| 1064 | + | return &MockConfigTransformer_Expecter{mock: &_m.Mock} | |||
| 1065 | + | } | |||
| 1066 | + | ||||
| 1067 | + | // TransformLegacyActions provides a mock function for the type MockConfigTransformer | |||
| 1068 | + | func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |||
| 1069 | + | _mock.Called(rawConfig) | |||
| 1070 | + | return | |||
| 1071 | + | } | |||
| 1072 | + | ||||
| 1073 | + | // MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions' | |||
| 1074 | + | type MockConfigTransformer_TransformLegacyActions_Call struct { | |||
| 1075 | + | *mock.Call | |||
| 1076 | + | } | |||
| 1077 | + | ||||
| 1078 | + | // TransformLegacyActions is a helper method to define mock.On call | |||
| 1079 | + | // - rawConfig map[string]any | |||
| 1080 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1081 | + | return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)} | |||
| 1082 | + | } | |||
| 1083 | + | ||||
| 1084 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1085 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1086 | + | var arg0 map[string]any | |||
| 1087 | + | if args[0] != nil { | |||
| 1088 | + | arg0 = args[0].(map[string]any) | |||
| 1089 | + | } | |||
| 1090 | + | run( | |||
| 1091 | + | arg0, | |||
| 1092 | + | ) | |||
| 1093 | + | }) | |||
| 1094 | + | return _c | |||
| 1095 | + | } | |||
| 1096 | + | ||||
| 1097 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1098 | + | _c.Call.Return() | |||
| 1099 | + | return _c | |||
| 1100 | + | } | |||
| 1101 | + | ||||
| 1102 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1103 | + | _c.Run(run) | |||
| 1104 | + | return _c | |||
| 1105 | + | } | |||
| 1106 | + | ||||
| 1040 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1107 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1041 | // The first argument is typically a *testing.T value. | 1108 | // The first argument is typically a *testing.T value. | ||
| 1042 | func NewMockDatabaseProvider(t interface { | 1109 | func NewMockDatabaseProvider(t interface { | ||
A internal/application/usecase/transform_legacy_config.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |
| 4 | + | ||
| 5 | + | // TransformLegacyConfigUseCase handles legacy config format transformation. | |
| 6 | + | type TransformLegacyConfigUseCase struct { | |
| 7 | + | transformer port.ConfigTransformer | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | // NewTransformLegacyConfigUseCase creates a new use case instance. | |
| 11 | + | func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *TransformLegacyConfigUseCase { | |
| 12 | + | return &TransformLegacyConfigUseCase{transformer: transformer} | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // Execute transforms legacy config format in place. | |
| 16 | + | func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) { | |
| 17 | + | uc.transformer.TransformLegacyActions(rawConfig) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |||
| 4 | + | ||||
| 5 | + | // TransformLegacyConfigUseCase handles legacy config format transformation. | |||
| 6 | + | type TransformLegacyConfigUseCase struct { | |||
| 7 | + | transformer port.ConfigTransformer | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | // NewTransformLegacyConfigUseCase creates a new use case instance. | |||
| 11 | + | func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *TransformLegacyConfigUseCase { | |||
| 12 | + | return &TransformLegacyConfigUseCase{transformer: transformer} | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // Execute transforms legacy config format in place. | |||
| 16 | + | func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) { | |||
| 17 | + | uc.transformer.TransformLegacyActions(rawConfig) | |||
| 18 | + | } | |||
A internal/application/usecase/transform_legacy_config_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestTransformLegacyConfigUseCase_Execute(t *testing.T) { | |
| 11 | + | mockTransformer := mocks.NewMockConfigTransformer(t) | |
| 12 | + | ||
| 13 | + | rawConfig := map[string]any{"test": "data"} | |
| 14 | + | ||
| 15 | + | mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return() | |
| 16 | + | ||
| 17 | + | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |
| 18 | + | uc.Execute(rawConfig) | |
| 19 | + | ||
| 20 | + | // AssertExpectations is called automatically via t.Cleanup | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) { | |
| 24 | + | mockTransformer := mocks.NewMockConfigTransformer(t) | |
| 25 | + | ||
| 26 | + | rawConfig := map[string]any{ | |
| 27 | + | "workspace": map[string]any{ | |
| 28 | + | "pane_mode": map[string]any{ | |
| 29 | + | "actions": map[string]any{ | |
| 30 | + | "focus-left": []any{"h"}, | |
| 31 | + | }, | |
| 32 | + | }, | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Verify the transformer receives the exact rawConfig | |
| 37 | + | mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return() | |
| 38 | + | ||
| 39 | + | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |
| 40 | + | uc.Execute(rawConfig) | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 7 | + | "github.com/stretchr/testify/mock" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestTransformLegacyConfigUseCase_Execute(t *testing.T) { | |||
| 11 | + | mockTransformer := mocks.NewMockConfigTransformer(t) | |||
| 12 | + | ||||
| 13 | + | rawConfig := map[string]any{"test": "data"} | |||
| 14 | + | ||||
| 15 | + | mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return() | |||
| 16 | + | ||||
| 17 | + | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |||
| 18 | + | uc.Execute(rawConfig) | |||
| 19 | + | ||||
| 20 | + | // AssertExpectations is called automatically via t.Cleanup | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) { | |||
| 24 | + | mockTransformer := mocks.NewMockConfigTransformer(t) | |||
| 25 | + | ||||
| 26 | + | rawConfig := map[string]any{ | |||
| 27 | + | "workspace": map[string]any{ | |||
| 28 | + | "pane_mode": map[string]any{ | |||
| 29 | + | "actions": map[string]any{ | |||
| 30 | + | "focus-left": []any{"h"}, | |||
| 31 | + | }, | |||
| 32 | + | }, | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Verify the transformer receives the exact rawConfig | |||
| 37 | + | mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return() | |||
| 38 | + | ||||
| 39 | + | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |||
| 40 | + | uc.Execute(rawConfig) | |||
| 41 | + | } | |||
M internal/infrastructure/config/loader.go +17 -0
| @@ -130,9 +130,26 @@ func (m *Manager) readConfigFile() error { | |||
| 130 | 130 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | |
| 131 | 131 | } | |
| 132 | 132 | } | |
| 133 | + | ||
| 134 | + | // Transform legacy action bindings before unmarshaling | |
| 135 | + | m.transformLegacyActionBindings() | |
| 136 | + | ||
| 133 | 137 | return nil | |
| 134 | 138 | } | |
| 135 | 139 | ||
| 140 | + | // transformLegacyActionBindings converts old-format action bindings to new format. | |
| 141 | + | // This is called after reading config but before unmarshaling. | |
| 142 | + | func (m *Manager) transformLegacyActionBindings() { | |
| 143 | + | rawConfig := m.viper.AllSettings() | |
| 144 | + | transformer := NewLegacyConfigTransformer() | |
| 145 | + | transformer.TransformLegacyActions(rawConfig) | |
| 146 | + | ||
| 147 | + | // Apply transformed config back to viper | |
| 148 | + | for key, value := range rawConfig { | |
| 149 | + | m.viper.Set(key, value) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 136 | 153 | func (m *Manager) unmarshalConfig() (*Config, error) { | |
| 137 | 154 | config := &Config{} | |
| 138 | 155 | if err := m.viper.Unmarshal(config); err != nil { | |
| @@ -130,9 +130,26 @@ func (m *Manager) readConfigFile() error { | |||||
| 130 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | 130 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | ||
| 131 | } | 131 | } | ||
| 132 | } | 132 | } | ||
| 133 | + | ||||
| 134 | + | // Transform legacy action bindings before unmarshaling | |||
| 135 | + | m.transformLegacyActionBindings() | |||
| 136 | + | ||||
| 133 | return nil | 137 | return nil | ||
| 134 | } | 138 | } | ||
| 135 | 139 | ||||
| 140 | + | // transformLegacyActionBindings converts old-format action bindings to new format. | |||
| 141 | + | // This is called after reading config but before unmarshaling. | |||
| 142 | + | func (m *Manager) transformLegacyActionBindings() { | |||
| 143 | + | rawConfig := m.viper.AllSettings() | |||
| 144 | + | transformer := NewLegacyConfigTransformer() | |||
| 145 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 146 | + | ||||
| 147 | + | // Apply transformed config back to viper | |||
| 148 | + | for key, value := range rawConfig { | |||
| 149 | + | m.viper.Set(key, value) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 136 | func (m *Manager) unmarshalConfig() (*Config, error) { | 153 | func (m *Manager) unmarshalConfig() (*Config, error) { | ||
| 137 | config := &Config{} | 154 | config := &Config{} | ||
| 138 | if err := m.viper.Unmarshal(config); err != nil { | 155 | if err := m.viper.Unmarshal(config); err != nil { | ||
M internal/infrastructure/config/migrate.go +8 -0
| @@ -371,6 +371,10 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 371 | 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| 372 | 372 | } | |
| 373 | 373 | ||
| 374 | + | // Transform legacy action bindings (slice -> ActionBinding struct) | |
| 375 | + | transformer := NewLegacyConfigTransformer() | |
| 376 | + | transformer.TransformLegacyActions(rawConfig) | |
| 377 | + | ||
| 374 | 378 | // Build sets of keys to remove and renames to apply | |
| 375 | 379 | keysToRemove := make(map[string]bool) | |
| 376 | 380 | renames := make(map[string]string) // oldKey -> newKey | |
| @@ -635,6 +639,10 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an | |||
| 635 | 639 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | |
| 636 | 640 | } | |
| 637 | 641 | ||
| 642 | + | // Transform legacy action bindings before flattening | |
| 643 | + | transformer := NewLegacyConfigTransformer() | |
| 644 | + | transformer.TransformLegacyActions(rawConfig) | |
| 645 | + | ||
| 638 | 646 | // Flatten the map to dot-notation keys with values | |
| 639 | 647 | result := make(map[string]any) | |
| 640 | 648 | m.flattenMapWithValues(rawConfig, "", result) | |
| @@ -371,6 +371,10 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| 372 | } | 372 | } | ||
| 373 | 373 | ||||
| 374 | + | // Transform legacy action bindings (slice -> ActionBinding struct) | |||
| 375 | + | transformer := NewLegacyConfigTransformer() | |||
| 376 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 377 | + | ||||
| 374 | // Build sets of keys to remove and renames to apply | 378 | // Build sets of keys to remove and renames to apply | ||
| 375 | keysToRemove := make(map[string]bool) | 379 | keysToRemove := make(map[string]bool) | ||
| 376 | renames := make(map[string]string) // oldKey -> newKey | 380 | renames := make(map[string]string) // oldKey -> newKey | ||
| @@ -635,6 +639,10 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an | |||||
| 635 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | 639 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | ||
| 636 | } | 640 | } | ||
| 637 | 641 | ||||
| 642 | + | // Transform legacy action bindings before flattening | |||
| 643 | + | transformer := NewLegacyConfigTransformer() | |||
| 644 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 645 | + | ||||
| 638 | // Flatten the map to dot-notation keys with values | 646 | // Flatten the map to dot-notation keys with values | ||
| 639 | result := make(map[string]any) | 647 | result := make(map[string]any) | ||
| 640 | m.flattenMapWithValues(rawConfig, "", result) | 648 | m.flattenMapWithValues(rawConfig, "", result) | ||
A internal/infrastructure/config/transform_legacy.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | const ( | |
| 4 | + | sectionWorkspace = "workspace" | |
| 5 | + | sectionSession = "session" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // LegacyConfigTransformer implements port.ConfigTransformer. | |
| 9 | + | // It transforms old-format action bindings (slices) to new ActionBinding format. | |
| 10 | + | type LegacyConfigTransformer struct { | |
| 11 | + | defaults *Config | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // NewLegacyConfigTransformer creates a new transformer with defaults for description lookup. | |
| 15 | + | func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |
| 16 | + | return &LegacyConfigTransformer{defaults: DefaultConfig()} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // TransformLegacyActions converts old-format action bindings to new format. | |
| 20 | + | // This handles migration from: | |
| 21 | + | // | |
| 22 | + | // [workspace.pane_mode.actions] | |
| 23 | + | // "focus-left" = ["h", "arrowleft"] | |
| 24 | + | // | |
| 25 | + | // To: | |
| 26 | + | // | |
| 27 | + | // [workspace.pane_mode.actions.focus-left] | |
| 28 | + | // keys = ["h", "arrowleft"] | |
| 29 | + | // desc = "Focus pane to the left" | |
| 30 | + | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |
| 31 | + | actionPaths := [][]string{ | |
| 32 | + | {"workspace", "pane_mode", "actions"}, | |
| 33 | + | {"workspace", "tab_mode", "actions"}, | |
| 34 | + | {"workspace", "resize_mode", "actions"}, | |
| 35 | + | {"workspace", "shortcuts", "actions"}, | |
| 36 | + | {"session", "session_mode", "actions"}, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | for _, path := range actionPaths { | |
| 40 | + | t.transformActionSection(rawConfig, path) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |
| 45 | + | // Navigate to the parent of the actions section | |
| 46 | + | current := rawConfig | |
| 47 | + | for _, key := range path[:len(path)-1] { | |
| 48 | + | next, ok := current[key].(map[string]any) | |
| 49 | + | if !ok { | |
| 50 | + | return // Path doesn't exist | |
| 51 | + | } | |
| 52 | + | current = next | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | actionsKey := path[len(path)-1] | |
| 56 | + | actionsRaw, ok := current[actionsKey] | |
| 57 | + | if !ok { | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | actions, ok := actionsRaw.(map[string]any) | |
| 62 | + | if !ok { | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Check each action and transform if needed | |
| 67 | + | for actionName, actionValue := range actions { | |
| 68 | + | // If value is a slice (old format), convert to ActionBinding format | |
| 69 | + | if slice, ok := actionValue.([]any); ok { | |
| 70 | + | keys := make([]string, 0, len(slice)) | |
| 71 | + | for _, v := range slice { | |
| 72 | + | if s, ok := v.(string); ok { | |
| 73 | + | keys = append(keys, s) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | actions[actionName] = map[string]any{ | |
| 78 | + | "keys": keys, | |
| 79 | + | "desc": t.getDefaultDesc(path, actionName), | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string { | |
| 86 | + | switch { | |
| 87 | + | case path[0] == sectionWorkspace && path[1] == "pane_mode": | |
| 88 | + | if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok { | |
| 89 | + | return b.Desc | |
| 90 | + | } | |
| 91 | + | case path[0] == sectionWorkspace && path[1] == "tab_mode": | |
| 92 | + | if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok { | |
| 93 | + | return b.Desc | |
| 94 | + | } | |
| 95 | + | case path[0] == sectionWorkspace && path[1] == "resize_mode": | |
| 96 | + | if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok { | |
| 97 | + | return b.Desc | |
| 98 | + | } | |
| 99 | + | case path[0] == sectionWorkspace && path[1] == "shortcuts": | |
| 100 | + | if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok { | |
| 101 | + | return b.Desc | |
| 102 | + | } | |
| 103 | + | case path[0] == sectionSession && path[1] == "session_mode": | |
| 104 | + | if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok { | |
| 105 | + | return b.Desc | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return "" | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | const ( | |||
| 4 | + | sectionWorkspace = "workspace" | |||
| 5 | + | sectionSession = "session" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // LegacyConfigTransformer implements port.ConfigTransformer. | |||
| 9 | + | // It transforms old-format action bindings (slices) to new ActionBinding format. | |||
| 10 | + | type LegacyConfigTransformer struct { | |||
| 11 | + | defaults *Config | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // NewLegacyConfigTransformer creates a new transformer with defaults for description lookup. | |||
| 15 | + | func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||
| 16 | + | return &LegacyConfigTransformer{defaults: DefaultConfig()} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // TransformLegacyActions converts old-format action bindings to new format. | |||
| 20 | + | // This handles migration from: | |||
| 21 | + | // | |||
| 22 | + | // [workspace.pane_mode.actions] | |||
| 23 | + | // "focus-left" = ["h", "arrowleft"] | |||
| 24 | + | // | |||
| 25 | + | // To: | |||
| 26 | + | // | |||
| 27 | + | // [workspace.pane_mode.actions.focus-left] | |||
| 28 | + | // keys = ["h", "arrowleft"] | |||
| 29 | + | // desc = "Focus pane to the left" | |||
| 30 | + | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |||
| 31 | + | actionPaths := [][]string{ | |||
| 32 | + | {"workspace", "pane_mode", "actions"}, | |||
| 33 | + | {"workspace", "tab_mode", "actions"}, | |||
| 34 | + | {"workspace", "resize_mode", "actions"}, | |||
| 35 | + | {"workspace", "shortcuts", "actions"}, | |||
| 36 | + | {"session", "session_mode", "actions"}, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | for _, path := range actionPaths { | |||
| 40 | + | t.transformActionSection(rawConfig, path) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |||
| 45 | + | // Navigate to the parent of the actions section | |||
| 46 | + | current := rawConfig | |||
| 47 | + | for _, key := range path[:len(path)-1] { | |||
| 48 | + | next, ok := current[key].(map[string]any) | |||
| 49 | + | if !ok { | |||
| 50 | + | return // Path doesn't exist | |||
| 51 | + | } | |||
| 52 | + | current = next | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | actionsKey := path[len(path)-1] | |||
| 56 | + | actionsRaw, ok := current[actionsKey] | |||
| 57 | + | if !ok { | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | actions, ok := actionsRaw.(map[string]any) | |||
| 62 | + | if !ok { | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Check each action and transform if needed | |||
| 67 | + | for actionName, actionValue := range actions { | |||
| 68 | + | // If value is a slice (old format), convert to ActionBinding format | |||
| 69 | + | if slice, ok := actionValue.([]any); ok { | |||
| 70 | + | keys := make([]string, 0, len(slice)) | |||
| 71 | + | for _, v := range slice { | |||
| 72 | + | if s, ok := v.(string); ok { | |||
| 73 | + | keys = append(keys, s) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | actions[actionName] = map[string]any{ | |||
| 78 | + | "keys": keys, | |||
| 79 | + | "desc": t.getDefaultDesc(path, actionName), | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string { | |||
| 86 | + | switch { | |||
| 87 | + | case path[0] == sectionWorkspace && path[1] == "pane_mode": | |||
| 88 | + | if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok { | |||
| 89 | + | return b.Desc | |||
| 90 | + | } | |||
| 91 | + | case path[0] == sectionWorkspace && path[1] == "tab_mode": | |||
| 92 | + | if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok { | |||
| 93 | + | return b.Desc | |||
| 94 | + | } | |||
| 95 | + | case path[0] == sectionWorkspace && path[1] == "resize_mode": | |||
| 96 | + | if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok { | |||
| 97 | + | return b.Desc | |||
| 98 | + | } | |||
| 99 | + | case path[0] == sectionWorkspace && path[1] == "shortcuts": | |||
| 100 | + | if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok { | |||
| 101 | + | return b.Desc | |||
| 102 | + | } | |||
| 103 | + | case path[0] == sectionSession && path[1] == "session_mode": | |||
| 104 | + | if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok { | |||
| 105 | + | return b.Desc | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return "" | |||
| 109 | + | } | |||
A internal/infrastructure/config/transform_legacy_test.go +193 -0
| @@ -0,0 +1,193 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestTransformLegacyActions_SliceFormat(t *testing.T) { | |
| 11 | + | transformer := NewLegacyConfigTransformer() | |
| 12 | + | ||
| 13 | + | rawConfig := map[string]any{ | |
| 14 | + | "workspace": map[string]any{ | |
| 15 | + | "pane_mode": map[string]any{ | |
| 16 | + | "actions": map[string]any{ | |
| 17 | + | "focus-left": []any{"h", "arrowleft"}, | |
| 18 | + | }, | |
| 19 | + | }, | |
| 20 | + | }, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | transformer.TransformLegacyActions(rawConfig) | |
| 24 | + | ||
| 25 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |
| 26 | + | binding, ok := actions["focus-left"].(map[string]any) | |
| 27 | + | require.True(t, ok, "should be converted to map") | |
| 28 | + | ||
| 29 | + | keys, ok := binding["keys"].([]string) | |
| 30 | + | require.True(t, ok, "keys should be []string") | |
| 31 | + | assert.Equal(t, []string{"h", "arrowleft"}, keys) | |
| 32 | + | assert.NotEmpty(t, binding["desc"], "should have description from defaults") | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestTransformLegacyActions_AllModes(t *testing.T) { | |
| 36 | + | transformer := NewLegacyConfigTransformer() | |
| 37 | + | ||
| 38 | + | rawConfig := map[string]any{ | |
| 39 | + | "workspace": map[string]any{ | |
| 40 | + | "pane_mode": map[string]any{ | |
| 41 | + | "actions": map[string]any{ | |
| 42 | + | "focus-left": []any{"h"}, | |
| 43 | + | }, | |
| 44 | + | }, | |
| 45 | + | "tab_mode": map[string]any{ | |
| 46 | + | "actions": map[string]any{ | |
| 47 | + | "next-tab": []any{"l"}, | |
| 48 | + | }, | |
| 49 | + | }, | |
| 50 | + | "resize_mode": map[string]any{ | |
| 51 | + | "actions": map[string]any{ | |
| 52 | + | "resize-increase": []any{"+"}, | |
| 53 | + | }, | |
| 54 | + | }, | |
| 55 | + | "shortcuts": map[string]any{ | |
| 56 | + | "actions": map[string]any{ | |
| 57 | + | "close_pane": []any{"ctrl+w"}, | |
| 58 | + | }, | |
| 59 | + | }, | |
| 60 | + | }, | |
| 61 | + | "session": map[string]any{ | |
| 62 | + | "session_mode": map[string]any{ | |
| 63 | + | "actions": map[string]any{ | |
| 64 | + | "session-manager": []any{"m"}, | |
| 65 | + | }, | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | transformer.TransformLegacyActions(rawConfig) | |
| 71 | + | ||
| 72 | + | // Check pane mode | |
| 73 | + | paneActions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |
| 74 | + | paneFocus, ok := paneActions["focus-left"].(map[string]any) | |
| 75 | + | require.True(t, ok) | |
| 76 | + | assert.Equal(t, []string{"h"}, paneFocus["keys"]) | |
| 77 | + | ||
| 78 | + | // Check tab mode | |
| 79 | + | tabActions := rawConfig["workspace"].(map[string]any)["tab_mode"].(map[string]any)["actions"].(map[string]any) | |
| 80 | + | tabNext, ok := tabActions["next-tab"].(map[string]any) | |
| 81 | + | require.True(t, ok) | |
| 82 | + | assert.Equal(t, []string{"l"}, tabNext["keys"]) | |
| 83 | + | ||
| 84 | + | // Check resize mode | |
| 85 | + | resizeActions := rawConfig["workspace"].(map[string]any)["resize_mode"].(map[string]any)["actions"].(map[string]any) | |
| 86 | + | resizeInc, ok := resizeActions["resize-increase"].(map[string]any) | |
| 87 | + | require.True(t, ok) | |
| 88 | + | assert.Equal(t, []string{"+"}, resizeInc["keys"]) | |
| 89 | + | ||
| 90 | + | // Check shortcuts | |
| 91 | + | shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any) | |
| 92 | + | closePane, ok := shortcutActions["close_pane"].(map[string]any) | |
| 93 | + | require.True(t, ok) | |
| 94 | + | assert.Equal(t, []string{"ctrl+w"}, closePane["keys"]) | |
| 95 | + | ||
| 96 | + | // Check session mode | |
| 97 | + | sessionActions := rawConfig["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any) | |
| 98 | + | sessionMgr, ok := sessionActions["session-manager"].(map[string]any) | |
| 99 | + | require.True(t, ok) | |
| 100 | + | assert.Equal(t, []string{"m"}, sessionMgr["keys"]) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestTransformLegacyActions_AlreadyNewFormat(t *testing.T) { | |
| 104 | + | transformer := NewLegacyConfigTransformer() | |
| 105 | + | ||
| 106 | + | rawConfig := map[string]any{ | |
| 107 | + | "workspace": map[string]any{ | |
| 108 | + | "pane_mode": map[string]any{ | |
| 109 | + | "actions": map[string]any{ | |
| 110 | + | "focus-left": map[string]any{ | |
| 111 | + | "keys": []string{"h"}, | |
| 112 | + | "desc": "Custom description", | |
| 113 | + | }, | |
| 114 | + | }, | |
| 115 | + | }, | |
| 116 | + | }, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | transformer.TransformLegacyActions(rawConfig) | |
| 120 | + | ||
| 121 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |
| 122 | + | binding := actions["focus-left"].(map[string]any) | |
| 123 | + | ||
| 124 | + | // Should remain unchanged | |
| 125 | + | assert.Equal(t, "Custom description", binding["desc"]) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestTransformLegacyActions_MissingSection(t *testing.T) { | |
| 129 | + | transformer := NewLegacyConfigTransformer() | |
| 130 | + | ||
| 131 | + | rawConfig := map[string]any{ | |
| 132 | + | "appearance": map[string]any{ | |
| 133 | + | "theme": "dark", | |
| 134 | + | }, | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Should not panic | |
| 138 | + | transformer.TransformLegacyActions(rawConfig) | |
| 139 | + | ||
| 140 | + | // Config should be unchanged | |
| 141 | + | assert.Equal(t, "dark", rawConfig["appearance"].(map[string]any)["theme"]) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestTransformLegacyActions_EmptyConfig(t *testing.T) { | |
| 145 | + | transformer := NewLegacyConfigTransformer() | |
| 146 | + | ||
| 147 | + | rawConfig := map[string]any{} | |
| 148 | + | ||
| 149 | + | // Should not panic | |
| 150 | + | transformer.TransformLegacyActions(rawConfig) | |
| 151 | + | ||
| 152 | + | assert.Empty(t, rawConfig) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestTransformLegacyActions_PartialPath(t *testing.T) { | |
| 156 | + | transformer := NewLegacyConfigTransformer() | |
| 157 | + | ||
| 158 | + | rawConfig := map[string]any{ | |
| 159 | + | "workspace": map[string]any{ | |
| 160 | + | "pane_mode": map[string]any{ | |
| 161 | + | // actions section missing | |
| 162 | + | }, | |
| 163 | + | }, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Should not panic | |
| 167 | + | transformer.TransformLegacyActions(rawConfig) | |
| 168 | + | ||
| 169 | + | // Verify config is unchanged | |
| 170 | + | require.NotNil(t, rawConfig["workspace"]) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |
| 174 | + | transformer := NewLegacyConfigTransformer() | |
| 175 | + | ||
| 176 | + | rawConfig := map[string]any{ | |
| 177 | + | "workspace": map[string]any{ | |
| 178 | + | "pane_mode": map[string]any{ | |
| 179 | + | "actions": map[string]any{ | |
| 180 | + | "unknown-action": []any{"x"}, | |
| 181 | + | }, | |
| 182 | + | }, | |
| 183 | + | }, | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | transformer.TransformLegacyActions(rawConfig) | |
| 187 | + | ||
| 188 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |
| 189 | + | binding, ok := actions["unknown-action"].(map[string]any) | |
| 190 | + | require.True(t, ok) | |
| 191 | + | assert.Equal(t, []string{"x"}, binding["keys"]) | |
| 192 | + | assert.Empty(t, binding["desc"], "unknown action should have empty description") | |
| 193 | + | } | |
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestTransformLegacyActions_SliceFormat(t *testing.T) { | |||
| 11 | + | transformer := NewLegacyConfigTransformer() | |||
| 12 | + | ||||
| 13 | + | rawConfig := map[string]any{ | |||
| 14 | + | "workspace": map[string]any{ | |||
| 15 | + | "pane_mode": map[string]any{ | |||
| 16 | + | "actions": map[string]any{ | |||
| 17 | + | "focus-left": []any{"h", "arrowleft"}, | |||
| 18 | + | }, | |||
| 19 | + | }, | |||
| 20 | + | }, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 24 | + | ||||
| 25 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 26 | + | binding, ok := actions["focus-left"].(map[string]any) | |||
| 27 | + | require.True(t, ok, "should be converted to map") | |||
| 28 | + | ||||
| 29 | + | keys, ok := binding["keys"].([]string) | |||
| 30 | + | require.True(t, ok, "keys should be []string") | |||
| 31 | + | assert.Equal(t, []string{"h", "arrowleft"}, keys) | |||
| 32 | + | assert.NotEmpty(t, binding["desc"], "should have description from defaults") | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestTransformLegacyActions_AllModes(t *testing.T) { | |||
| 36 | + | transformer := NewLegacyConfigTransformer() | |||
| 37 | + | ||||
| 38 | + | rawConfig := map[string]any{ | |||
| 39 | + | "workspace": map[string]any{ | |||
| 40 | + | "pane_mode": map[string]any{ | |||
| 41 | + | "actions": map[string]any{ | |||
| 42 | + | "focus-left": []any{"h"}, | |||
| 43 | + | }, | |||
| 44 | + | }, | |||
| 45 | + | "tab_mode": map[string]any{ | |||
| 46 | + | "actions": map[string]any{ | |||
| 47 | + | "next-tab": []any{"l"}, | |||
| 48 | + | }, | |||
| 49 | + | }, | |||
| 50 | + | "resize_mode": map[string]any{ | |||
| 51 | + | "actions": map[string]any{ | |||
| 52 | + | "resize-increase": []any{"+"}, | |||
| 53 | + | }, | |||
| 54 | + | }, | |||
| 55 | + | "shortcuts": map[string]any{ | |||
| 56 | + | "actions": map[string]any{ | |||
| 57 | + | "close_pane": []any{"ctrl+w"}, | |||
| 58 | + | }, | |||
| 59 | + | }, | |||
| 60 | + | }, | |||
| 61 | + | "session": map[string]any{ | |||
| 62 | + | "session_mode": map[string]any{ | |||
| 63 | + | "actions": map[string]any{ | |||
| 64 | + | "session-manager": []any{"m"}, | |||
| 65 | + | }, | |||
| 66 | + | }, | |||
| 67 | + | }, | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 71 | + | ||||
| 72 | + | // Check pane mode | |||
| 73 | + | paneActions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 74 | + | paneFocus, ok := paneActions["focus-left"].(map[string]any) | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | assert.Equal(t, []string{"h"}, paneFocus["keys"]) | |||
| 77 | + | ||||
| 78 | + | // Check tab mode | |||
| 79 | + | tabActions := rawConfig["workspace"].(map[string]any)["tab_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 80 | + | tabNext, ok := tabActions["next-tab"].(map[string]any) | |||
| 81 | + | require.True(t, ok) | |||
| 82 | + | assert.Equal(t, []string{"l"}, tabNext["keys"]) | |||
| 83 | + | ||||
| 84 | + | // Check resize mode | |||
| 85 | + | resizeActions := rawConfig["workspace"].(map[string]any)["resize_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 86 | + | resizeInc, ok := resizeActions["resize-increase"].(map[string]any) | |||
| 87 | + | require.True(t, ok) | |||
| 88 | + | assert.Equal(t, []string{"+"}, resizeInc["keys"]) | |||
| 89 | + | ||||
| 90 | + | // Check shortcuts | |||
| 91 | + | shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any) | |||
| 92 | + | closePane, ok := shortcutActions["close_pane"].(map[string]any) | |||
| 93 | + | require.True(t, ok) | |||
| 94 | + | assert.Equal(t, []string{"ctrl+w"}, closePane["keys"]) | |||
| 95 | + | ||||
| 96 | + | // Check session mode | |||
| 97 | + | sessionActions := rawConfig["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 98 | + | sessionMgr, ok := sessionActions["session-manager"].(map[string]any) | |||
| 99 | + | require.True(t, ok) | |||
| 100 | + | assert.Equal(t, []string{"m"}, sessionMgr["keys"]) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestTransformLegacyActions_AlreadyNewFormat(t *testing.T) { | |||
| 104 | + | transformer := NewLegacyConfigTransformer() | |||
| 105 | + | ||||
| 106 | + | rawConfig := map[string]any{ | |||
| 107 | + | "workspace": map[string]any{ | |||
| 108 | + | "pane_mode": map[string]any{ | |||
| 109 | + | "actions": map[string]any{ | |||
| 110 | + | "focus-left": map[string]any{ | |||
| 111 | + | "keys": []string{"h"}, | |||
| 112 | + | "desc": "Custom description", | |||
| 113 | + | }, | |||
| 114 | + | }, | |||
| 115 | + | }, | |||
| 116 | + | }, | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 120 | + | ||||
| 121 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 122 | + | binding := actions["focus-left"].(map[string]any) | |||
| 123 | + | ||||
| 124 | + | // Should remain unchanged | |||
| 125 | + | assert.Equal(t, "Custom description", binding["desc"]) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestTransformLegacyActions_MissingSection(t *testing.T) { | |||
| 129 | + | transformer := NewLegacyConfigTransformer() | |||
| 130 | + | ||||
| 131 | + | rawConfig := map[string]any{ | |||
| 132 | + | "appearance": map[string]any{ | |||
| 133 | + | "theme": "dark", | |||
| 134 | + | }, | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Should not panic | |||
| 138 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 139 | + | ||||
| 140 | + | // Config should be unchanged | |||
| 141 | + | assert.Equal(t, "dark", rawConfig["appearance"].(map[string]any)["theme"]) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestTransformLegacyActions_EmptyConfig(t *testing.T) { | |||
| 145 | + | transformer := NewLegacyConfigTransformer() | |||
| 146 | + | ||||
| 147 | + | rawConfig := map[string]any{} | |||
| 148 | + | ||||
| 149 | + | // Should not panic | |||
| 150 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 151 | + | ||||
| 152 | + | assert.Empty(t, rawConfig) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestTransformLegacyActions_PartialPath(t *testing.T) { | |||
| 156 | + | transformer := NewLegacyConfigTransformer() | |||
| 157 | + | ||||
| 158 | + | rawConfig := map[string]any{ | |||
| 159 | + | "workspace": map[string]any{ | |||
| 160 | + | "pane_mode": map[string]any{ | |||
| 161 | + | // actions section missing | |||
| 162 | + | }, | |||
| 163 | + | }, | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Should not panic | |||
| 167 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 168 | + | ||||
| 169 | + | // Verify config is unchanged | |||
| 170 | + | require.NotNil(t, rawConfig["workspace"]) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |||
| 174 | + | transformer := NewLegacyConfigTransformer() | |||
| 175 | + | ||||
| 176 | + | rawConfig := map[string]any{ | |||
| 177 | + | "workspace": map[string]any{ | |||
| 178 | + | "pane_mode": map[string]any{ | |||
| 179 | + | "actions": map[string]any{ | |||
| 180 | + | "unknown-action": []any{"x"}, | |||
| 181 | + | }, | |||
| 182 | + | }, | |||
| 183 | + | }, | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 187 | + | ||||
| 188 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 189 | + | binding, ok := actions["unknown-action"].(map[string]any) | |||
| 190 | + | require.True(t, ok) | |||
| 191 | + | assert.Equal(t, []string{"x"}, binding["keys"]) | |||
| 192 | + | assert.Empty(t, binding["desc"], "unknown action should have empty description") | |||
| 193 | + | } | |||
M internal/infrastructure/webkit/messaging.go +10 -2
| @@ -213,7 +213,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 213 | 213 | ||
| 214 | 214 | entry, ok := r.getHandler(msg.Type) | |
| 215 | 215 | if !ok || entry.handler == nil { | |
| 216 | - | log.Warn().Str("type", msg.Type).Msg("no handler registered for message type") | |
| 216 | + | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type") | |
| 217 | + | // Debug: list all registered handlers | |
| 218 | + | r.mu.RLock() | |
| 219 | + | for handlerType := range r.handlers { | |
| 220 | + | log.Debug().Str("registered_type", handlerType).Msg("available handler") | |
| 221 | + | } | |
| 222 | + | r.mu.RUnlock() | |
| 217 | 223 | return | |
| 218 | 224 | } | |
| 219 | 225 | ||
| @@ -221,9 +227,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 221 | 227 | Str("type", msg.Type). | |
| 222 | 228 | Uint64("webview_id", msg.WebViewID). | |
| 223 | 229 | Int("payload_len", len(msg.Payload)). | |
| 224 | - | Msg("received script message") | |
| 230 | + | Str("callback", entry.callback). | |
| 231 | + | Msg("received script message, dispatching to handler") | |
| 225 | 232 | ||
| 226 | 233 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | |
| 234 | + | log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") | |
| 227 | 235 | if err != nil { | |
| 228 | 236 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | |
| 229 | 237 | if entry.errorCallback != "" { | |
| @@ -213,7 +213,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 213 | 213 | ||||
| 214 | entry, ok := r.getHandler(msg.Type) | 214 | entry, ok := r.getHandler(msg.Type) | ||
| 215 | if !ok || entry.handler == nil { | 215 | if !ok || entry.handler == nil { | ||
| 216 | - | log.Warn().Str("type", msg.Type).Msg("no handler registered for message type") | 216 | + | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type") |
| 217 | + | // Debug: list all registered handlers | |||
| 218 | + | r.mu.RLock() | |||
| 219 | + | for handlerType := range r.handlers { | |||
| 220 | + | log.Debug().Str("registered_type", handlerType).Msg("available handler") | |||
| 221 | + | } | |||
| 222 | + | r.mu.RUnlock() | |||
| 217 | return | 223 | return | ||
| 218 | } | 224 | } | ||
| 219 | 225 | ||||
| @@ -221,9 +227,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 221 | Str("type", msg.Type). | 227 | Str("type", msg.Type). | ||
| 222 | Uint64("webview_id", msg.WebViewID). | 228 | Uint64("webview_id", msg.WebViewID). | ||
| 223 | Int("payload_len", len(msg.Payload)). | 229 | Int("payload_len", len(msg.Payload)). | ||
| 224 | - | Msg("received script message") | 230 | + | Str("callback", entry.callback). |
| 231 | + | Msg("received script message, dispatching to handler") | |||
| 225 | 232 | ||||
| 226 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | 233 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | ||
| 234 | + | log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") | |||
| 227 | if err != nil { | 235 | if err != nil { | ||
| 228 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | 236 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | ||
| 229 | if entry.errorCallback != "" { | 237 | if entry.errorCallback != "" { | ||
M webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +12 -2
| @@ -2,16 +2,26 @@ | |||
| 2 | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |
| 3 | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | |
| 4 | 4 | import { cn } from "$lib/utils.js"; | |
| 5 | + | import type { Snippet } from "svelte"; | |
| 6 | + | import type { MouseEventHandler } from "svelte/elements"; | |
| 5 | 7 | ||
| 6 | 8 | let { | |
| 7 | 9 | ref = $bindable(null), | |
| 8 | 10 | class: className, | |
| 11 | + | children, | |
| 12 | + | onclick, | |
| 9 | 13 | ...restProps | |
| 10 | - | }: AlertDialogPrimitive.ActionProps = $props(); | |
| 14 | + | }: AlertDialogPrimitive.ActionProps & { | |
| 15 | + | children?: Snippet; | |
| 16 | + | onclick?: MouseEventHandler<HTMLButtonElement>; | |
| 17 | + | } = $props(); | |
| 11 | 18 | </script> | |
| 12 | 19 | ||
| 13 | 20 | <AlertDialogPrimitive.Action | |
| 14 | 21 | bind:ref | |
| 15 | 22 | class={cn(buttonVariants(), className)} | |
| 23 | + | {onclick} | |
| 16 | 24 | {...restProps} | |
| 17 | - | /> | |
| 25 | + | > | |
| 26 | + | {@render children?.()} | |
| 27 | + | </AlertDialogPrimitive.Action> | |
| @@ -2,16 +2,26 @@ | |||||
| 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | ||
| 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | ||
| 4 | import { cn } from "$lib/utils.js"; | 4 | import { cn } from "$lib/utils.js"; | ||
| 5 | + | import type { Snippet } from "svelte"; | |||
| 6 | + | import type { MouseEventHandler } from "svelte/elements"; | |||
| 5 | 7 | ||||
| 6 | let { | 8 | let { | ||
| 7 | ref = $bindable(null), | 9 | ref = $bindable(null), | ||
| 8 | class: className, | 10 | class: className, | ||
| 11 | + | children, | |||
| 12 | + | onclick, | |||
| 9 | ...restProps | 13 | ...restProps | ||
| 10 | - | }: AlertDialogPrimitive.ActionProps = $props(); | 14 | + | }: AlertDialogPrimitive.ActionProps & { |
| 15 | + | children?: Snippet; | |||
| 16 | + | onclick?: MouseEventHandler<HTMLButtonElement>; | |||
| 17 | + | } = $props(); | |||
| 11 | </script> | 18 | </script> | ||
| 12 | 19 | ||||
| 13 | <AlertDialogPrimitive.Action | 20 | <AlertDialogPrimitive.Action | ||
| 14 | bind:ref | 21 | bind:ref | ||
| 15 | class={cn(buttonVariants(), className)} | 22 | class={cn(buttonVariants(), className)} | ||
| 23 | + | {onclick} | |||
| 16 | {...restProps} | 24 | {...restProps} | ||
| 17 | - | /> | 25 | + | > |
| 26 | + | {@render children?.()} | |||
| 27 | + | </AlertDialogPrimitive.Action> | |||
M webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +8 -2
| @@ -2,16 +2,22 @@ | |||
| 2 | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |
| 3 | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | |
| 4 | 4 | import { cn } from "$lib/utils.js"; | |
| 5 | + | import type { Snippet } from "svelte"; | |
| 5 | 6 | ||
| 6 | 7 | let { | |
| 7 | 8 | ref = $bindable(null), | |
| 8 | 9 | class: className, | |
| 10 | + | children, | |
| 9 | 11 | ...restProps | |
| 10 | - | }: AlertDialogPrimitive.CancelProps = $props(); | |
| 12 | + | }: AlertDialogPrimitive.CancelProps & { | |
| 13 | + | children?: Snippet; | |
| 14 | + | } = $props(); | |
| 11 | 15 | </script> | |
| 12 | 16 | ||
| 13 | 17 | <AlertDialogPrimitive.Cancel | |
| 14 | 18 | bind:ref | |
| 15 | 19 | class={cn(buttonVariants({ variant: "outline" }), className)} | |
| 16 | 20 | {...restProps} | |
| 17 | - | /> | |
| 21 | + | > | |
| 22 | + | {@render children?.()} | |
| 23 | + | </AlertDialogPrimitive.Cancel> | |
| @@ -2,16 +2,22 @@ | |||||
| 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | ||
| 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | ||
| 4 | import { cn } from "$lib/utils.js"; | 4 | import { cn } from "$lib/utils.js"; | ||
| 5 | + | import type { Snippet } from "svelte"; | |||
| 5 | 6 | ||||
| 6 | let { | 7 | let { | ||
| 7 | ref = $bindable(null), | 8 | ref = $bindable(null), | ||
| 8 | class: className, | 9 | class: className, | ||
| 10 | + | children, | |||
| 9 | ...restProps | 11 | ...restProps | ||
| 10 | - | }: AlertDialogPrimitive.CancelProps = $props(); | 12 | + | }: AlertDialogPrimitive.CancelProps & { |
| 13 | + | children?: Snippet; | |||
| 14 | + | } = $props(); | |||
| 11 | </script> | 15 | </script> | ||
| 12 | 16 | ||||
| 13 | <AlertDialogPrimitive.Cancel | 17 | <AlertDialogPrimitive.Cancel | ||
| 14 | bind:ref | 18 | bind:ref | ||
| 15 | class={cn(buttonVariants({ variant: "outline" }), className)} | 19 | class={cn(buttonVariants({ variant: "outline" }), className)} | ||
| 16 | {...restProps} | 20 | {...restProps} | ||
| 17 | - | /> | 21 | + | > |
| 22 | + | {@render children?.()} | |||
| 23 | + | </AlertDialogPrimitive.Cancel> | |||
M webui/src/pages/config/KeyCaptureModal.svelte +1 -1
| @@ -189,7 +189,7 @@ | |||
| 189 | 189 | ||
| 190 | 190 | <AlertDialog.Footer> | |
| 191 | 191 | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |
| 192 | - | <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action> | |
| 192 | + | <AlertDialog.Action onclick={() => { console.log("[DEBUG] Save clicked, newKeys:", newKeys); onSave(newKeys); }}>Save</AlertDialog.Action> | |
| 193 | 193 | </AlertDialog.Footer> | |
| 194 | 194 | </AlertDialog.Content> | |
| 195 | 195 | </AlertDialog.Root> | |
| @@ -189,7 +189,7 @@ | |||||
| 189 | 189 | ||||
| 190 | <AlertDialog.Footer> | 190 | <AlertDialog.Footer> | ||
| 191 | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | 191 | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | ||
| 192 | - | <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action> | 192 | + | <AlertDialog.Action onclick={() => { console.log("[DEBUG] Save clicked, newKeys:", newKeys); onSave(newKeys); }}>Save</AlertDialog.Action> |
| 193 | </AlertDialog.Footer> | 193 | </AlertDialog.Footer> | ||
| 194 | </AlertDialog.Content> | 194 | </AlertDialog.Content> | ||
| 195 | </AlertDialog.Root> | 195 | </AlertDialog.Root> | ||
M webui/src/pages/config/KeybindingsTab.svelte +10 -2
| @@ -74,10 +74,18 @@ | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | async function saveBinding(keys: string[]) { | |
| 77 | - | if (!editingBinding) return; | |
| 77 | + | console.log("[DEBUG] saveBinding called with keys:", keys, "editingBinding:", editingBinding); | |
| 78 | + | if (!editingBinding) { | |
| 79 | + | console.log("[DEBUG] saveBinding: no editingBinding, returning"); | |
| 80 | + | return; | |
| 81 | + | } | |
| 78 | 82 | ||
| 79 | 83 | const bridge = getWebKitBridge(); | |
| 80 | - | if (!bridge) return; | |
| 84 | + | console.log("[DEBUG] saveBinding: bridge =", bridge); | |
| 85 | + | if (!bridge) { | |
| 86 | + | console.log("[DEBUG] saveBinding: no bridge, returning"); | |
| 87 | + | return; | |
| 88 | + | } | |
| 81 | 89 | ||
| 82 | 90 | (window as any).__dumber_keybinding_set = () => { | |
| 83 | 91 | loadKeybindings(); | |
| @@ -74,10 +74,18 @@ | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | async function saveBinding(keys: string[]) { | 76 | async function saveBinding(keys: string[]) { | ||
| 77 | - | if (!editingBinding) return; | 77 | + | console.log("[DEBUG] saveBinding called with keys:", keys, "editingBinding:", editingBinding); |
| 78 | + | if (!editingBinding) { | |||
| 79 | + | console.log("[DEBUG] saveBinding: no editingBinding, returning"); | |||
| 80 | + | return; | |||
| 81 | + | } | |||
| 78 | 82 | ||||
| 79 | const bridge = getWebKitBridge(); | 83 | const bridge = getWebKitBridge(); | ||
| 80 | - | if (!bridge) return; | 84 | + | console.log("[DEBUG] saveBinding: bridge =", bridge); |
| 85 | + | if (!bridge) { | |||
| 86 | + | console.log("[DEBUG] saveBinding: no bridge, returning"); | |||
| 87 | + | return; | |||
| 88 | + | } | |||
| 81 | 89 | ||||
| 82 | (window as any).__dumber_keybinding_set = () => { | 90 | (window as any).__dumber_keybinding_set = () => { | ||
| 83 | loadKeybindings(); | 91 | loadKeybindings(); | ||