fix: improve config migration rename detection and fix mode toaster colors
- commit
- fe2336cf1e0edfbbc98e35578da75fcfda52d6bd
- author
- brice · · 2025-12-28 06:45 UTC
- parent
- 4e82c5d
- signature
- unsigned
15 files · +1134 -88
M internal/application/port/config_migrator.go +44 -1
| @@ -18,14 +18,57 @@ type KeyInfo struct { | |||
| 18 | 18 | DefaultValue string | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | + | // KeyChangeType indicates the type of config key change. | |
| 22 | + | type KeyChangeType int | |
| 23 | + | ||
| 24 | + | const ( | |
| 25 | + | // KeyChangeAdded indicates a new key was added to defaults. | |
| 26 | + | KeyChangeAdded KeyChangeType = iota | |
| 27 | + | // KeyChangeRemoved indicates a key in user config no longer exists in defaults. | |
| 28 | + | KeyChangeRemoved | |
| 29 | + | // KeyChangeRenamed indicates a key was renamed (detected via similarity). | |
| 30 | + | KeyChangeRenamed | |
| 31 | + | // KeyChangeConsolidated indicates multiple keys were merged into one. | |
| 32 | + | KeyChangeConsolidated | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // String returns a display symbol for the change type. | |
| 36 | + | func (t KeyChangeType) String() string { | |
| 37 | + | switch t { | |
| 38 | + | case KeyChangeAdded: | |
| 39 | + | return "+" | |
| 40 | + | case KeyChangeRemoved: | |
| 41 | + | return "-" | |
| 42 | + | case KeyChangeRenamed: | |
| 43 | + | return "~" | |
| 44 | + | case KeyChangeConsolidated: | |
| 45 | + | return ">" | |
| 46 | + | default: | |
| 47 | + | return "?" | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // KeyChange represents a detected change between user config and defaults. | |
| 52 | + | type KeyChange struct { | |
| 53 | + | Type KeyChangeType // Type of change | |
| 54 | + | OldKey string // Old key name (for removed/renamed) | |
| 55 | + | NewKey string // New key name (for added/renamed) | |
| 56 | + | OldValue string // Old value (for renamed/consolidated) | |
| 57 | + | NewValue string // New/default value | |
| 58 | + | } | |
| 59 | + | ||
| 21 | 60 | // ConfigMigrator checks for and applies config migrations. | |
| 22 | 61 | type ConfigMigrator interface { | |
| 23 | 62 | // CheckMigration checks if user config is missing any default keys. | |
| 24 | 63 | // Returns nil if no migration is needed (config file doesn't exist or is complete). | |
| 25 | 64 | CheckMigration() (*MigrationResult, error) | |
| 26 | 65 | ||
| 66 | + | // DetectChanges analyzes user config and returns all detected changes. | |
| 67 | + | // This provides a detailed diff-like view of what migration would do. | |
| 68 | + | DetectChanges() ([]KeyChange, error) | |
| 69 | + | ||
| 27 | 70 | // Migrate adds missing default keys to the user's config file. | |
| 28 | - | // Returns the list of keys that were added. | |
| 71 | + | // Returns the list of keys that were added/renamed. | |
| 29 | 72 | Migrate() ([]string, error) | |
| 30 | 73 | ||
| 31 | 74 | // GetKeyInfo returns detailed information about a config key. | |
| @@ -18,14 +18,57 @@ type KeyInfo struct { | |||||
| 18 | DefaultValue string | 18 | DefaultValue string | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | + | // KeyChangeType indicates the type of config key change. | |||
| 22 | + | type KeyChangeType int | |||
| 23 | + | ||||
| 24 | + | const ( | |||
| 25 | + | // KeyChangeAdded indicates a new key was added to defaults. | |||
| 26 | + | KeyChangeAdded KeyChangeType = iota | |||
| 27 | + | // KeyChangeRemoved indicates a key in user config no longer exists in defaults. | |||
| 28 | + | KeyChangeRemoved | |||
| 29 | + | // KeyChangeRenamed indicates a key was renamed (detected via similarity). | |||
| 30 | + | KeyChangeRenamed | |||
| 31 | + | // KeyChangeConsolidated indicates multiple keys were merged into one. | |||
| 32 | + | KeyChangeConsolidated | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // String returns a display symbol for the change type. | |||
| 36 | + | func (t KeyChangeType) String() string { | |||
| 37 | + | switch t { | |||
| 38 | + | case KeyChangeAdded: | |||
| 39 | + | return "+" | |||
| 40 | + | case KeyChangeRemoved: | |||
| 41 | + | return "-" | |||
| 42 | + | case KeyChangeRenamed: | |||
| 43 | + | return "~" | |||
| 44 | + | case KeyChangeConsolidated: | |||
| 45 | + | return ">" | |||
| 46 | + | default: | |||
| 47 | + | return "?" | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // KeyChange represents a detected change between user config and defaults. | |||
| 52 | + | type KeyChange struct { | |||
| 53 | + | Type KeyChangeType // Type of change | |||
| 54 | + | OldKey string // Old key name (for removed/renamed) | |||
| 55 | + | NewKey string // New key name (for added/renamed) | |||
| 56 | + | OldValue string // Old value (for renamed/consolidated) | |||
| 57 | + | NewValue string // New/default value | |||
| 58 | + | } | |||
| 59 | + | ||||
| 21 | // ConfigMigrator checks for and applies config migrations. | 60 | // ConfigMigrator checks for and applies config migrations. | ||
| 22 | type ConfigMigrator interface { | 61 | type ConfigMigrator interface { | ||
| 23 | // CheckMigration checks if user config is missing any default keys. | 62 | // CheckMigration checks if user config is missing any default keys. | ||
| 24 | // Returns nil if no migration is needed (config file doesn't exist or is complete). | 63 | // Returns nil if no migration is needed (config file doesn't exist or is complete). | ||
| 25 | CheckMigration() (*MigrationResult, error) | 64 | CheckMigration() (*MigrationResult, error) | ||
| 26 | 65 | ||||
| 66 | + | // DetectChanges analyzes user config and returns all detected changes. | |||
| 67 | + | // This provides a detailed diff-like view of what migration would do. | |||
| 68 | + | DetectChanges() ([]KeyChange, error) | |||
| 69 | + | ||||
| 27 | // Migrate adds missing default keys to the user's config file. | 70 | // Migrate adds missing default keys to the user's config file. | ||
| 28 | - | // Returns the list of keys that were added. | 71 | + | // Returns the list of keys that were added/renamed. |
| 29 | Migrate() ([]string, error) | 72 | Migrate() ([]string, error) | ||
| 30 | 73 | ||||
| 31 | // GetKeyInfo returns detailed information about a config key. | 74 | // GetKeyInfo returns detailed information about a config key. | ||
M internal/application/port/mocks/mocks.go +55 -0
| @@ -96,6 +96,61 @@ func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port | |||
| 96 | 96 | return _c | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // DetectChanges provides a mock function for the type MockConfigMigrator | |
| 100 | + | func (_mock *MockConfigMigrator) DetectChanges() ([]port.KeyChange, error) { | |
| 101 | + | ret := _mock.Called() | |
| 102 | + | ||
| 103 | + | if len(ret) == 0 { | |
| 104 | + | panic("no return value specified for DetectChanges") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | var r0 []port.KeyChange | |
| 108 | + | var r1 error | |
| 109 | + | if returnFunc, ok := ret.Get(0).(func() ([]port.KeyChange, error)); ok { | |
| 110 | + | return returnFunc() | |
| 111 | + | } | |
| 112 | + | if returnFunc, ok := ret.Get(0).(func() []port.KeyChange); ok { | |
| 113 | + | r0 = returnFunc() | |
| 114 | + | } else { | |
| 115 | + | if ret.Get(0) != nil { | |
| 116 | + | r0 = ret.Get(0).([]port.KeyChange) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 120 | + | r1 = returnFunc() | |
| 121 | + | } else { | |
| 122 | + | r1 = ret.Error(1) | |
| 123 | + | } | |
| 124 | + | return r0, r1 | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // MockConfigMigrator_DetectChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectChanges' | |
| 128 | + | type MockConfigMigrator_DetectChanges_Call struct { | |
| 129 | + | *mock.Call | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // DetectChanges is a helper method to define mock.On call | |
| 133 | + | func (_e *MockConfigMigrator_Expecter) DetectChanges() *MockConfigMigrator_DetectChanges_Call { | |
| 134 | + | return &MockConfigMigrator_DetectChanges_Call{Call: _e.mock.On("DetectChanges")} | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (_c *MockConfigMigrator_DetectChanges_Call) Run(run func()) *MockConfigMigrator_DetectChanges_Call { | |
| 138 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 139 | + | run() | |
| 140 | + | }) | |
| 141 | + | return _c | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (_c *MockConfigMigrator_DetectChanges_Call) Return(keyChanges []port.KeyChange, err error) *MockConfigMigrator_DetectChanges_Call { | |
| 145 | + | _c.Call.Return(keyChanges, err) | |
| 146 | + | return _c | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port.KeyChange, error)) *MockConfigMigrator_DetectChanges_Call { | |
| 150 | + | _c.Call.Return(run) | |
| 151 | + | return _c | |
| 152 | + | } | |
| 153 | + | ||
| 99 | 154 | // GetKeyInfo provides a mock function for the type MockConfigMigrator | |
| 100 | 155 | func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo { | |
| 101 | 156 | ret := _mock.Called(key) | |
| @@ -96,6 +96,61 @@ func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port | |||||
| 96 | return _c | 96 | return _c | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | // DetectChanges provides a mock function for the type MockConfigMigrator | |||
| 100 | + | func (_mock *MockConfigMigrator) DetectChanges() ([]port.KeyChange, error) { | |||
| 101 | + | ret := _mock.Called() | |||
| 102 | + | ||||
| 103 | + | if len(ret) == 0 { | |||
| 104 | + | panic("no return value specified for DetectChanges") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | var r0 []port.KeyChange | |||
| 108 | + | var r1 error | |||
| 109 | + | if returnFunc, ok := ret.Get(0).(func() ([]port.KeyChange, error)); ok { | |||
| 110 | + | return returnFunc() | |||
| 111 | + | } | |||
| 112 | + | if returnFunc, ok := ret.Get(0).(func() []port.KeyChange); ok { | |||
| 113 | + | r0 = returnFunc() | |||
| 114 | + | } else { | |||
| 115 | + | if ret.Get(0) != nil { | |||
| 116 | + | r0 = ret.Get(0).([]port.KeyChange) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 120 | + | r1 = returnFunc() | |||
| 121 | + | } else { | |||
| 122 | + | r1 = ret.Error(1) | |||
| 123 | + | } | |||
| 124 | + | return r0, r1 | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // MockConfigMigrator_DetectChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectChanges' | |||
| 128 | + | type MockConfigMigrator_DetectChanges_Call struct { | |||
| 129 | + | *mock.Call | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // DetectChanges is a helper method to define mock.On call | |||
| 133 | + | func (_e *MockConfigMigrator_Expecter) DetectChanges() *MockConfigMigrator_DetectChanges_Call { | |||
| 134 | + | return &MockConfigMigrator_DetectChanges_Call{Call: _e.mock.On("DetectChanges")} | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func (_c *MockConfigMigrator_DetectChanges_Call) Run(run func()) *MockConfigMigrator_DetectChanges_Call { | |||
| 138 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 139 | + | run() | |||
| 140 | + | }) | |||
| 141 | + | return _c | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (_c *MockConfigMigrator_DetectChanges_Call) Return(keyChanges []port.KeyChange, err error) *MockConfigMigrator_DetectChanges_Call { | |||
| 145 | + | _c.Call.Return(keyChanges, err) | |||
| 146 | + | return _c | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port.KeyChange, error)) *MockConfigMigrator_DetectChanges_Call { | |||
| 150 | + | _c.Call.Return(run) | |||
| 151 | + | return _c | |||
| 152 | + | } | |||
| 153 | + | ||||
| 99 | // GetKeyInfo provides a mock function for the type MockConfigMigrator | 154 | // GetKeyInfo provides a mock function for the type MockConfigMigrator | ||
| 100 | func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo { | 155 | func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo { | ||
| 101 | ret := _mock.Called(key) | 156 | ret := _mock.Called(key) | ||
M internal/application/usecase/migrate_config.go +71 -0
| @@ -2,6 +2,8 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 9 | "github.com/bnema/dumber/internal/logging" | |
| @@ -20,6 +22,19 @@ type CheckConfigMigrationOutput struct { | |||
| 20 | 22 | ConfigFile string | |
| 21 | 23 | } | |
| 22 | 24 | ||
| 25 | + | // DetectChangesInput holds the input for detecting config changes. | |
| 26 | + | type DetectChangesInput struct{} | |
| 27 | + | ||
| 28 | + | // DetectChangesOutput holds the result of change detection. | |
| 29 | + | type DetectChangesOutput struct { | |
| 30 | + | // HasChanges is true if any changes were detected. | |
| 31 | + | HasChanges bool | |
| 32 | + | // Changes contains all detected changes. | |
| 33 | + | Changes []port.KeyChange | |
| 34 | + | // DiffText is a formatted diff-like string representation. | |
| 35 | + | DiffText string | |
| 36 | + | } | |
| 37 | + | ||
| 23 | 38 | // MigrateConfigInput holds the input for migrating config. | |
| 24 | 39 | type MigrateConfigInput struct{} | |
| 25 | 40 | ||
| @@ -81,6 +96,36 @@ func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigratio | |||
| 81 | 96 | }, nil | |
| 82 | 97 | } | |
| 83 | 98 | ||
| 99 | + | // DetectChanges detects all config changes and returns a diff-like output. | |
| 100 | + | func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChangesInput) (*DetectChangesOutput, error) { | |
| 101 | + | log := logging.FromContext(ctx) | |
| 102 | + | ||
| 103 | + | changes, err := uc.migrator.DetectChanges() | |
| 104 | + | if err != nil { | |
| 105 | + | log.Warn().Err(err).Msg("config change detection failed") | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | if len(changes) == 0 { | |
| 110 | + | log.Debug().Msg("no config changes detected") | |
| 111 | + | return &DetectChangesOutput{ | |
| 112 | + | HasChanges: false, | |
| 113 | + | Changes: nil, | |
| 114 | + | DiffText: "No changes detected.", | |
| 115 | + | }, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | log.Debug(). | |
| 119 | + | Int("changes", len(changes)). | |
| 120 | + | Msg("config changes detected") | |
| 121 | + | ||
| 122 | + | return &DetectChangesOutput{ | |
| 123 | + | HasChanges: true, | |
| 124 | + | Changes: changes, | |
| 125 | + | DiffText: formatChangesAsDiff(changes), | |
| 126 | + | }, nil | |
| 127 | + | } | |
| 128 | + | ||
| 84 | 129 | // Execute adds missing default keys to the user's config file. | |
| 85 | 130 | func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) { | |
| 86 | 131 | log := logging.FromContext(ctx) | |
| @@ -116,3 +161,29 @@ func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInpu | |||
| 116 | 161 | ConfigFile: checkResult.ConfigFile, | |
| 117 | 162 | }, nil | |
| 118 | 163 | } | |
| 164 | + | ||
| 165 | + | // formatChangesAsDiff returns changes formatted as a diff for display. | |
| 166 | + | func formatChangesAsDiff(changes []port.KeyChange) string { | |
| 167 | + | if len(changes) == 0 { | |
| 168 | + | return "No changes detected." | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | var sb strings.Builder | |
| 172 | + | sb.WriteString("Config migration changes:\n\n") | |
| 173 | + | ||
| 174 | + | for _, change := range changes { | |
| 175 | + | switch change.Type { | |
| 176 | + | case port.KeyChangeAdded: | |
| 177 | + | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |
| 178 | + | case port.KeyChangeRemoved: | |
| 179 | + | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |
| 180 | + | case port.KeyChangeRenamed: | |
| 181 | + | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |
| 182 | + | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |
| 183 | + | case port.KeyChangeConsolidated: | |
| 184 | + | sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey)) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | return sb.String() | |
| 189 | + | } | |
| @@ -2,6 +2,8 @@ package usecase | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 5 | 7 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -20,6 +22,19 @@ type CheckConfigMigrationOutput struct { | |||||
| 20 | ConfigFile string | 22 | ConfigFile string | ||
| 21 | } | 23 | } | ||
| 22 | 24 | ||||
| 25 | + | // DetectChangesInput holds the input for detecting config changes. | |||
| 26 | + | type DetectChangesInput struct{} | |||
| 27 | + | ||||
| 28 | + | // DetectChangesOutput holds the result of change detection. | |||
| 29 | + | type DetectChangesOutput struct { | |||
| 30 | + | // HasChanges is true if any changes were detected. | |||
| 31 | + | HasChanges bool | |||
| 32 | + | // Changes contains all detected changes. | |||
| 33 | + | Changes []port.KeyChange | |||
| 34 | + | // DiffText is a formatted diff-like string representation. | |||
| 35 | + | DiffText string | |||
| 36 | + | } | |||
| 37 | + | ||||
| 23 | // MigrateConfigInput holds the input for migrating config. | 38 | // MigrateConfigInput holds the input for migrating config. | ||
| 24 | type MigrateConfigInput struct{} | 39 | type MigrateConfigInput struct{} | ||
| 25 | 40 | ||||
| @@ -81,6 +96,36 @@ func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigratio | |||||
| 81 | }, nil | 96 | }, nil | ||
| 82 | } | 97 | } | ||
| 83 | 98 | ||||
| 99 | + | // DetectChanges detects all config changes and returns a diff-like output. | |||
| 100 | + | func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChangesInput) (*DetectChangesOutput, error) { | |||
| 101 | + | log := logging.FromContext(ctx) | |||
| 102 | + | ||||
| 103 | + | changes, err := uc.migrator.DetectChanges() | |||
| 104 | + | if err != nil { | |||
| 105 | + | log.Warn().Err(err).Msg("config change detection failed") | |||
| 106 | + | return nil, err | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | if len(changes) == 0 { | |||
| 110 | + | log.Debug().Msg("no config changes detected") | |||
| 111 | + | return &DetectChangesOutput{ | |||
| 112 | + | HasChanges: false, | |||
| 113 | + | Changes: nil, | |||
| 114 | + | DiffText: "No changes detected.", | |||
| 115 | + | }, nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | log.Debug(). | |||
| 119 | + | Int("changes", len(changes)). | |||
| 120 | + | Msg("config changes detected") | |||
| 121 | + | ||||
| 122 | + | return &DetectChangesOutput{ | |||
| 123 | + | HasChanges: true, | |||
| 124 | + | Changes: changes, | |||
| 125 | + | DiffText: formatChangesAsDiff(changes), | |||
| 126 | + | }, nil | |||
| 127 | + | } | |||
| 128 | + | ||||
| 84 | // Execute adds missing default keys to the user's config file. | 129 | // Execute adds missing default keys to the user's config file. | ||
| 85 | func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) { | 130 | func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) { | ||
| 86 | log := logging.FromContext(ctx) | 131 | log := logging.FromContext(ctx) | ||
| @@ -116,3 +161,29 @@ func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInpu | |||||
| 116 | ConfigFile: checkResult.ConfigFile, | 161 | ConfigFile: checkResult.ConfigFile, | ||
| 117 | }, nil | 162 | }, nil | ||
| 118 | } | 163 | } | ||
| 164 | + | ||||
| 165 | + | // formatChangesAsDiff returns changes formatted as a diff for display. | |||
| 166 | + | func formatChangesAsDiff(changes []port.KeyChange) string { | |||
| 167 | + | if len(changes) == 0 { | |||
| 168 | + | return "No changes detected." | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | var sb strings.Builder | |||
| 172 | + | sb.WriteString("Config migration changes:\n\n") | |||
| 173 | + | ||||
| 174 | + | for _, change := range changes { | |||
| 175 | + | switch change.Type { | |||
| 176 | + | case port.KeyChangeAdded: | |||
| 177 | + | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |||
| 178 | + | case port.KeyChangeRemoved: | |||
| 179 | + | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |||
| 180 | + | case port.KeyChangeRenamed: | |||
| 181 | + | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |||
| 182 | + | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |||
| 183 | + | case port.KeyChangeConsolidated: | |||
| 184 | + | sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey)) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | return sb.String() | |||
| 189 | + | } | |||
M internal/application/usecase/migrate_config_test.go +182 -0
| @@ -204,3 +204,185 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |||
| 204 | 204 | assert.True(t, result.NeedsMigration) | |
| 205 | 205 | mockMigrator.AssertExpectations(t) | |
| 206 | 206 | } | |
| 207 | + | ||
| 208 | + | func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) { | |
| 209 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 210 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) | |
| 211 | + | ||
| 212 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 213 | + | ctx := context.Background() | |
| 214 | + | ||
| 215 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 216 | + | ||
| 217 | + | require.NoError(t, err) | |
| 218 | + | assert.False(t, result.HasChanges) | |
| 219 | + | assert.Empty(t, result.Changes) | |
| 220 | + | assert.Equal(t, "No changes detected.", result.DiffText) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) { | |
| 224 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 225 | + | ||
| 226 | + | changes := []port.KeyChange{ | |
| 227 | + | { | |
| 228 | + | Type: port.KeyChangeAdded, | |
| 229 | + | NewKey: "workspace.styling.mode_indicator_toaster_enabled", | |
| 230 | + | NewValue: "true", | |
| 231 | + | }, | |
| 232 | + | } | |
| 233 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 234 | + | ||
| 235 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 236 | + | ctx := context.Background() | |
| 237 | + | ||
| 238 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 239 | + | ||
| 240 | + | require.NoError(t, err) | |
| 241 | + | assert.True(t, result.HasChanges) | |
| 242 | + | assert.Len(t, result.Changes, 1) | |
| 243 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled") | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) { | |
| 247 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 248 | + | ||
| 249 | + | changes := []port.KeyChange{ | |
| 250 | + | { | |
| 251 | + | Type: port.KeyChangeRenamed, | |
| 252 | + | OldKey: "workspace.styling.pane_mode_border_color", | |
| 253 | + | NewKey: "workspace.styling.pane_mode_color", | |
| 254 | + | OldValue: `"#4A90E2"`, | |
| 255 | + | }, | |
| 256 | + | } | |
| 257 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 258 | + | ||
| 259 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 260 | + | ctx := context.Background() | |
| 261 | + | ||
| 262 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 263 | + | ||
| 264 | + | require.NoError(t, err) | |
| 265 | + | assert.True(t, result.HasChanges) | |
| 266 | + | assert.Len(t, result.Changes, 1) | |
| 267 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color") | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) { | |
| 271 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 272 | + | ||
| 273 | + | changes := []port.KeyChange{ | |
| 274 | + | { | |
| 275 | + | Type: port.KeyChangeRemoved, | |
| 276 | + | OldKey: "workspace.styling.pane_mode_border_width", | |
| 277 | + | OldValue: "4", | |
| 278 | + | }, | |
| 279 | + | } | |
| 280 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 281 | + | ||
| 282 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 283 | + | ctx := context.Background() | |
| 284 | + | ||
| 285 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 286 | + | ||
| 287 | + | require.NoError(t, err) | |
| 288 | + | assert.True(t, result.HasChanges) | |
| 289 | + | assert.Len(t, result.Changes, 1) | |
| 290 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |
| 291 | + | assert.Contains(t, result.DiffText, "(deprecated)") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) { | |
| 295 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 296 | + | expectedErr := errors.New("detect changes failed") | |
| 297 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) | |
| 298 | + | ||
| 299 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 300 | + | ctx := context.Background() | |
| 301 | + | ||
| 302 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 303 | + | ||
| 304 | + | require.Error(t, err) | |
| 305 | + | assert.Nil(t, result) | |
| 306 | + | assert.Equal(t, expectedErr, err) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) { | |
| 310 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 311 | + | ||
| 312 | + | changes := []port.KeyChange{ | |
| 313 | + | { | |
| 314 | + | Type: port.KeyChangeAdded, | |
| 315 | + | NewKey: "workspace.styling.mode_border_width", | |
| 316 | + | NewValue: "4", | |
| 317 | + | }, | |
| 318 | + | { | |
| 319 | + | Type: port.KeyChangeRenamed, | |
| 320 | + | OldKey: "workspace.styling.pane_mode_border_color", | |
| 321 | + | NewKey: "workspace.styling.pane_mode_color", | |
| 322 | + | OldValue: `"#4A90E2"`, | |
| 323 | + | }, | |
| 324 | + | { | |
| 325 | + | Type: port.KeyChangeRemoved, | |
| 326 | + | OldKey: "workspace.styling.pane_mode_border_width", | |
| 327 | + | OldValue: "4", | |
| 328 | + | }, | |
| 329 | + | } | |
| 330 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 331 | + | ||
| 332 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 333 | + | ctx := context.Background() | |
| 334 | + | ||
| 335 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 336 | + | ||
| 337 | + | require.NoError(t, err) | |
| 338 | + | assert.True(t, result.HasChanges) | |
| 339 | + | assert.Len(t, result.Changes, 3) | |
| 340 | + | ||
| 341 | + | // Verify diff contains all change types | |
| 342 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width") | |
| 343 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color") | |
| 344 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func TestFormatChangesAsDiff_EmptyChanges(t *testing.T) { | |
| 348 | + | result := formatChangesAsDiff(nil) | |
| 349 | + | assert.Equal(t, "No changes detected.", result) | |
| 350 | + | ||
| 351 | + | result = formatChangesAsDiff([]port.KeyChange{}) | |
| 352 | + | assert.Equal(t, "No changes detected.", result) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func TestFormatChangesAsDiff_AllChangeTypes(t *testing.T) { | |
| 356 | + | changes := []port.KeyChange{ | |
| 357 | + | { | |
| 358 | + | Type: port.KeyChangeAdded, | |
| 359 | + | NewKey: "new.key", | |
| 360 | + | NewValue: `"value"`, | |
| 361 | + | }, | |
| 362 | + | { | |
| 363 | + | Type: port.KeyChangeRemoved, | |
| 364 | + | OldKey: "old.key", | |
| 365 | + | OldValue: `"old_value"`, | |
| 366 | + | }, | |
| 367 | + | { | |
| 368 | + | Type: port.KeyChangeRenamed, | |
| 369 | + | OldKey: "renamed.old", | |
| 370 | + | NewKey: "renamed.new", | |
| 371 | + | OldValue: `"renamed_value"`, | |
| 372 | + | }, | |
| 373 | + | { | |
| 374 | + | Type: port.KeyChangeConsolidated, | |
| 375 | + | OldKey: "consolidated.old", | |
| 376 | + | NewKey: "consolidated.new", | |
| 377 | + | }, | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | result := formatChangesAsDiff(changes) | |
| 381 | + | ||
| 382 | + | assert.Contains(t, result, "Config migration changes:") | |
| 383 | + | assert.Contains(t, result, `+ new.key = "value"`) | |
| 384 | + | assert.Contains(t, result, `- old.key = "old_value" (deprecated)`) | |
| 385 | + | assert.Contains(t, result, "~ renamed.old -> renamed.new") | |
| 386 | + | assert.Contains(t, result, `(value: "renamed_value")`) | |
| 387 | + | assert.Contains(t, result, "> consolidated.old -> consolidated.new") | |
| 388 | + | } | |
| @@ -204,3 +204,185 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |||||
| 204 | assert.True(t, result.NeedsMigration) | 204 | assert.True(t, result.NeedsMigration) | ||
| 205 | mockMigrator.AssertExpectations(t) | 205 | mockMigrator.AssertExpectations(t) | ||
| 206 | } | 206 | } | ||
| 207 | + | ||||
| 208 | + | func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) { | |||
| 209 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 210 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) | |||
| 211 | + | ||||
| 212 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |||
| 213 | + | ctx := context.Background() | |||
| 214 | + | ||||
| 215 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 216 | + | ||||
| 217 | + | require.NoError(t, err) | |||
| 218 | + | assert.False(t, result.HasChanges) | |||
| 219 | + | assert.Empty(t, result.Changes) | |||
| 220 | + | assert.Equal(t, "No changes detected.", result.DiffText) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) { | |||
| 224 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 225 | + | ||||
| 226 | + | changes := []port.KeyChange{ | |||
| 227 | + | { | |||
| 228 | + | Type: port.KeyChangeAdded, | |||
| 229 | + | NewKey: "workspace.styling.mode_indicator_toaster_enabled", | |||
| 230 | + | NewValue: "true", | |||
| 231 | + | }, | |||
| 232 | + | } | |||
| 233 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 234 | + | ||||
| 235 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |||
| 236 | + | ctx := context.Background() | |||
| 237 | + | ||||
| 238 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 239 | + | ||||
| 240 | + | require.NoError(t, err) | |||
| 241 | + | assert.True(t, result.HasChanges) | |||
| 242 | + | assert.Len(t, result.Changes, 1) | |||
| 243 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) { | |||
| 247 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 248 | + | ||||
| 249 | + | changes := []port.KeyChange{ | |||
| 250 | + | { | |||
| 251 | + | Type: port.KeyChangeRenamed, | |||
| 252 | + | OldKey: "workspace.styling.pane_mode_border_color", | |||
| 253 | + | NewKey: "workspace.styling.pane_mode_color", | |||
| 254 | + | OldValue: `"#4A90E2"`, | |||
| 255 | + | }, | |||
| 256 | + | } | |||
| 257 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 258 | + | ||||
| 259 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |||
| 260 | + | ctx := context.Background() | |||
| 261 | + | ||||
| 262 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 263 | + | ||||
| 264 | + | require.NoError(t, err) | |||
| 265 | + | assert.True(t, result.HasChanges) | |||
| 266 | + | assert.Len(t, result.Changes, 1) | |||
| 267 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color") | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) { | |||
| 271 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 272 | + | ||||
| 273 | + | changes := []port.KeyChange{ | |||
| 274 | + | { | |||
| 275 | + | Type: port.KeyChangeRemoved, | |||
| 276 | + | OldKey: "workspace.styling.pane_mode_border_width", | |||
| 277 | + | OldValue: "4", | |||
| 278 | + | }, | |||
| 279 | + | } | |||
| 280 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 281 | + | ||||
| 282 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |||
| 283 | + | ctx := context.Background() | |||
| 284 | + | ||||
| 285 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 286 | + | ||||
| 287 | + | require.NoError(t, err) | |||
| 288 | + | assert.True(t, result.HasChanges) | |||
| 289 | + | assert.Len(t, result.Changes, 1) | |||
| 290 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |||
| 291 | + | assert.Contains(t, result.DiffText, "(deprecated)") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) { | |||
| 295 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 296 | + | expectedErr := errors.New("detect changes failed") | |||
| 297 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) | |||
| 298 | + | ||||
| 299 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |||
| 300 | + | ctx := context.Background() | |||
| 301 | + | ||||
| 302 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 303 | + | ||||
| 304 | + | require.Error(t, err) | |||
| 305 | + | assert.Nil(t, result) | |||
| 306 | + | assert.Equal(t, expectedErr, err) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) { | |||
| 310 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 311 | + | ||||
| 312 | + | changes := []port.KeyChange{ | |||
| 313 | + | { | |||
| 314 | + | Type: port.KeyChangeAdded, | |||
| 315 | + | NewKey: "workspace.styling.mode_border_width", | |||
| 316 | + | NewValue: "4", | |||
| 317 | + | }, | |||
| 318 | + | { | |||
| 319 | + | Type: port.KeyChangeRenamed, | |||
| 320 | + | OldKey: "workspace.styling.pane_mode_border_color", | |||
| 321 | + | NewKey: "workspace.styling.pane_mode_color", | |||
| 322 | + | OldValue: `"#4A90E2"`, | |||
| 323 | + | }, | |||
| 324 | + | { | |||
| 325 | + | Type: port.KeyChangeRemoved, | |||
| 326 | + | OldKey: "workspace.styling.pane_mode_border_width", | |||
| 327 | + | OldValue: "4", | |||
| 328 | + | }, | |||
| 329 | + | } | |||
| 330 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 331 | + | ||||
| 332 | + | uc := NewMigrateConfigUseCase(mockMigrator) | |||
| 333 | + | ctx := context.Background() | |||
| 334 | + | ||||
| 335 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 336 | + | ||||
| 337 | + | require.NoError(t, err) | |||
| 338 | + | assert.True(t, result.HasChanges) | |||
| 339 | + | assert.Len(t, result.Changes, 3) | |||
| 340 | + | ||||
| 341 | + | // Verify diff contains all change types | |||
| 342 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width") | |||
| 343 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color") | |||
| 344 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func TestFormatChangesAsDiff_EmptyChanges(t *testing.T) { | |||
| 348 | + | result := formatChangesAsDiff(nil) | |||
| 349 | + | assert.Equal(t, "No changes detected.", result) | |||
| 350 | + | ||||
| 351 | + | result = formatChangesAsDiff([]port.KeyChange{}) | |||
| 352 | + | assert.Equal(t, "No changes detected.", result) | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func TestFormatChangesAsDiff_AllChangeTypes(t *testing.T) { | |||
| 356 | + | changes := []port.KeyChange{ | |||
| 357 | + | { | |||
| 358 | + | Type: port.KeyChangeAdded, | |||
| 359 | + | NewKey: "new.key", | |||
| 360 | + | NewValue: `"value"`, | |||
| 361 | + | }, | |||
| 362 | + | { | |||
| 363 | + | Type: port.KeyChangeRemoved, | |||
| 364 | + | OldKey: "old.key", | |||
| 365 | + | OldValue: `"old_value"`, | |||
| 366 | + | }, | |||
| 367 | + | { | |||
| 368 | + | Type: port.KeyChangeRenamed, | |||
| 369 | + | OldKey: "renamed.old", | |||
| 370 | + | NewKey: "renamed.new", | |||
| 371 | + | OldValue: `"renamed_value"`, | |||
| 372 | + | }, | |||
| 373 | + | { | |||
| 374 | + | Type: port.KeyChangeConsolidated, | |||
| 375 | + | OldKey: "consolidated.old", | |||
| 376 | + | NewKey: "consolidated.new", | |||
| 377 | + | }, | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | result := formatChangesAsDiff(changes) | |||
| 381 | + | ||||
| 382 | + | assert.Contains(t, result, "Config migration changes:") | |||
| 383 | + | assert.Contains(t, result, `+ new.key = "value"`) | |||
| 384 | + | assert.Contains(t, result, `- old.key = "old_value" (deprecated)`) | |||
| 385 | + | assert.Contains(t, result, "~ renamed.old -> renamed.new") | |||
| 386 | + | assert.Contains(t, result, `(value: "renamed_value")`) | |||
| 387 | + | assert.Contains(t, result, "> consolidated.old -> consolidated.new") | |||
| 388 | + | } | |||
M internal/infrastructure/config/defaults.go +18 -24
| @@ -46,21 +46,17 @@ const ( | |||
| 46 | 46 | defaultBorderWidth = 1 | |
| 47 | 47 | defaultBorderColor = "@theme_selected_bg_color" | |
| 48 | 48 | ||
| 49 | - | // Pane mode border (Ctrl+P N - overlay) | |
| 50 | - | defaultPaneModeBorderWidth = 4 | |
| 51 | - | defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator | |
| 49 | + | // Mode border width (consolidated for all modes) | |
| 50 | + | defaultModeBorderWidth = 4 | |
| 52 | 51 | ||
| 53 | - | // Tab mode border (Ctrl+P T - overlay) | |
| 54 | - | defaultTabModeBorderWidth = 4 | |
| 55 | - | defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator | |
| 52 | + | // Mode colors (used for both borders and toaster) | |
| 53 | + | defaultPaneModeColor = "#4A90E2" // Blue for pane mode | |
| 54 | + | defaultTabModeColor = "#FFA500" // Orange for tab mode | |
| 55 | + | defaultSessionModeColor = "#9B59B6" // Purple for session mode | |
| 56 | + | defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode | |
| 56 | 57 | ||
| 57 | - | // Session mode border (Ctrl+O - overlay) | |
| 58 | - | defaultSessionModeBorderWidth = 4 | |
| 59 | - | defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator | |
| 60 | - | ||
| 61 | - | // Resize mode border (Ctrl+N - overlay) | |
| 62 | - | defaultResizeModeBorderWidth = 4 | |
| 63 | - | defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator | |
| 58 | + | // Mode indicator toaster | |
| 59 | + | defaultModeIndicatorToasterEnabled = true | |
| 64 | 60 | ||
| 65 | 61 | // Other styling | |
| 66 | 62 | defaultTransitionDuration = 120 | |
| @@ -238,17 +234,15 @@ func DefaultConfig() *Config { | |||
| 238 | 234 | OAuthAutoClose: true, // Auto-close OAuth popups on success | |
| 239 | 235 | }, | |
| 240 | 236 | Styling: WorkspaceStylingConfig{ | |
| 241 | - | BorderWidth: defaultBorderWidth, | |
| 242 | - | BorderColor: defaultBorderColor, | |
| 243 | - | PaneModeBorderWidth: defaultPaneModeBorderWidth, | |
| 244 | - | PaneModeBorderColor: defaultPaneModeBorderColor, | |
| 245 | - | TabModeBorderWidth: defaultTabModeBorderWidth, | |
| 246 | - | TabModeBorderColor: defaultTabModeBorderColor, | |
| 247 | - | SessionModeBorderWidth: defaultSessionModeBorderWidth, | |
| 248 | - | SessionModeBorderColor: defaultSessionModeBorderColor, | |
| 249 | - | ResizeModeBorderWidth: defaultResizeModeBorderWidth, | |
| 250 | - | ResizeModeBorderColor: defaultResizeModeBorderColor, | |
| 251 | - | TransitionDuration: defaultTransitionDuration, | |
| 237 | + | BorderWidth: defaultBorderWidth, | |
| 238 | + | BorderColor: defaultBorderColor, | |
| 239 | + | ModeBorderWidth: defaultModeBorderWidth, | |
| 240 | + | PaneModeColor: defaultPaneModeColor, | |
| 241 | + | TabModeColor: defaultTabModeColor, | |
| 242 | + | SessionModeColor: defaultSessionModeColor, | |
| 243 | + | ResizeModeColor: defaultResizeModeColor, | |
| 244 | + | ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled, | |
| 245 | + | TransitionDuration: defaultTransitionDuration, | |
| 252 | 246 | }, | |
| 253 | 247 | }, | |
| 254 | 248 | ContentFiltering: ContentFilteringConfig{ | |
| @@ -46,21 +46,17 @@ const ( | |||||
| 46 | defaultBorderWidth = 1 | 46 | defaultBorderWidth = 1 | ||
| 47 | defaultBorderColor = "@theme_selected_bg_color" | 47 | defaultBorderColor = "@theme_selected_bg_color" | ||
| 48 | 48 | ||||
| 49 | - | // Pane mode border (Ctrl+P N - overlay) | 49 | + | // Mode border width (consolidated for all modes) |
| 50 | - | defaultPaneModeBorderWidth = 4 | 50 | + | defaultModeBorderWidth = 4 |
| 51 | - | defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator | |||
| 52 | 51 | ||||
| 53 | - | // Tab mode border (Ctrl+P T - overlay) | 52 | + | // Mode colors (used for both borders and toaster) |
| 54 | - | defaultTabModeBorderWidth = 4 | 53 | + | defaultPaneModeColor = "#4A90E2" // Blue for pane mode |
| 55 | - | defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator | 54 | + | defaultTabModeColor = "#FFA500" // Orange for tab mode |
| 55 | + | defaultSessionModeColor = "#9B59B6" // Purple for session mode | |||
| 56 | + | defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode | |||
| 56 | 57 | ||||
| 57 | - | // Session mode border (Ctrl+O - overlay) | 58 | + | // Mode indicator toaster |
| 58 | - | defaultSessionModeBorderWidth = 4 | 59 | + | defaultModeIndicatorToasterEnabled = true |
| 59 | - | defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator | |||
| 60 | - | ||||
| 61 | - | // Resize mode border (Ctrl+N - overlay) | |||
| 62 | - | defaultResizeModeBorderWidth = 4 | |||
| 63 | - | defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator | |||
| 64 | 60 | ||||
| 65 | // Other styling | 61 | // Other styling | ||
| 66 | defaultTransitionDuration = 120 | 62 | defaultTransitionDuration = 120 | ||
| @@ -238,17 +234,15 @@ func DefaultConfig() *Config { | |||||
| 238 | OAuthAutoClose: true, // Auto-close OAuth popups on success | 234 | OAuthAutoClose: true, // Auto-close OAuth popups on success | ||
| 239 | }, | 235 | }, | ||
| 240 | Styling: WorkspaceStylingConfig{ | 236 | Styling: WorkspaceStylingConfig{ | ||
| 241 | - | BorderWidth: defaultBorderWidth, | 237 | + | BorderWidth: defaultBorderWidth, |
| 242 | - | BorderColor: defaultBorderColor, | 238 | + | BorderColor: defaultBorderColor, |
| 243 | - | PaneModeBorderWidth: defaultPaneModeBorderWidth, | 239 | + | ModeBorderWidth: defaultModeBorderWidth, |
| 244 | - | PaneModeBorderColor: defaultPaneModeBorderColor, | 240 | + | PaneModeColor: defaultPaneModeColor, |
| 245 | - | TabModeBorderWidth: defaultTabModeBorderWidth, | 241 | + | TabModeColor: defaultTabModeColor, |
| 246 | - | TabModeBorderColor: defaultTabModeBorderColor, | 242 | + | SessionModeColor: defaultSessionModeColor, |
| 247 | - | SessionModeBorderWidth: defaultSessionModeBorderWidth, | 243 | + | ResizeModeColor: defaultResizeModeColor, |
| 248 | - | SessionModeBorderColor: defaultSessionModeBorderColor, | 244 | + | ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled, |
| 249 | - | ResizeModeBorderWidth: defaultResizeModeBorderWidth, | 245 | + | TransitionDuration: defaultTransitionDuration, |
| 250 | - | ResizeModeBorderColor: defaultResizeModeBorderColor, | |||
| 251 | - | TransitionDuration: defaultTransitionDuration, | |||
| 252 | }, | 246 | }, | ||
| 253 | }, | 247 | }, | ||
| 254 | ContentFiltering: ContentFilteringConfig{ | 248 | ContentFiltering: ContentFilteringConfig{ | ||
M internal/infrastructure/config/loader.go +6 -8
| @@ -411,14 +411,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 411 | 411 | m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose) | |
| 412 | 412 | m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth) | |
| 413 | 413 | m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor) | |
| 414 | - | m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth) | |
| 415 | - | m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor) | |
| 416 | - | m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth) | |
| 417 | - | m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor) | |
| 418 | - | m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth) | |
| 419 | - | m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor) | |
| 420 | - | m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth) | |
| 421 | - | m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor) | |
| 414 | + | m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth) | |
| 415 | + | m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor) | |
| 416 | + | m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor) | |
| 417 | + | m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor) | |
| 418 | + | m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor) | |
| 419 | + | m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled) | |
| 422 | 420 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | |
| 423 | 421 | } | |
| 424 | 422 | ||
| @@ -411,14 +411,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 411 | m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose) | 411 | m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose) | ||
| 412 | m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth) | 412 | m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth) | ||
| 413 | m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor) | 413 | m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor) | ||
| 414 | - | m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth) | 414 | + | m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth) |
| 415 | - | m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor) | 415 | + | m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor) |
| 416 | - | m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth) | 416 | + | m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor) |
| 417 | - | m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor) | 417 | + | m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor) |
| 418 | - | m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth) | 418 | + | m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor) |
| 419 | - | m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor) | 419 | + | m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled) |
| 420 | - | m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth) | |||
| 421 | - | m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor) | |||
| 422 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | 420 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | ||
| 423 | } | 421 | } | ||
| 424 | 422 | ||||
M internal/infrastructure/config/migrate.go +362 -8
| @@ -71,20 +71,291 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) { | |||
| 71 | 71 | }, nil | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | // DetectChanges analyzes user config and returns all detected changes. | |
| 75 | + | // This provides a detailed diff-like view of what migration would do. | |
| 76 | + | func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |
| 77 | + | configFile, err := GetConfigFile() | |
| 78 | + | if err != nil { | |
| 79 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // If config file doesn't exist, no changes to detect | |
| 83 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 84 | + | return nil, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Get user-defined keys with values | |
| 88 | + | userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |
| 89 | + | if err != nil { | |
| 90 | + | return nil, fmt.Errorf("failed to parse user config: %w", err) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Get all default keys | |
| 94 | + | defaultKeys := m.getAllDefaultKeys() | |
| 95 | + | defaultKeySet := make(map[string]bool) | |
| 96 | + | for _, k := range defaultKeys { | |
| 97 | + | defaultKeySet[k] = true | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Build user key set for lookup | |
| 101 | + | userKeySet := make(map[string]bool) | |
| 102 | + | for k := range userKeysWithValues { | |
| 103 | + | userKeySet[k] = true | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | var changes []port.KeyChange | |
| 107 | + | ||
| 108 | + | // Find deprecated keys (in user config but not in defaults) | |
| 109 | + | deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet) | |
| 110 | + | ||
| 111 | + | // Find missing keys (in defaults but not in user config) | |
| 112 | + | missingKeys := m.findMissingKeys(defaultKeys, userKeySet) | |
| 113 | + | ||
| 114 | + | // Try to match deprecated keys with missing keys (detect renames) | |
| 115 | + | renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys) | |
| 116 | + | ||
| 117 | + | // Add rename changes | |
| 118 | + | for oldKey, newKey := range renames { | |
| 119 | + | oldValue := m.formatValue(userKeysWithValues[oldKey]) | |
| 120 | + | newValue := m.formatValue(m.defaultViper.Get(newKey)) | |
| 121 | + | changes = append(changes, port.KeyChange{ | |
| 122 | + | Type: port.KeyChangeRenamed, | |
| 123 | + | OldKey: oldKey, | |
| 124 | + | NewKey: newKey, | |
| 125 | + | OldValue: oldValue, | |
| 126 | + | NewValue: newValue, | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Add removed changes (deprecated keys that couldn't be matched) | |
| 131 | + | for _, oldKey := range unmatchedDeprecated { | |
| 132 | + | changes = append(changes, port.KeyChange{ | |
| 133 | + | Type: port.KeyChangeRemoved, | |
| 134 | + | OldKey: oldKey, | |
| 135 | + | OldValue: m.formatValue(userKeysWithValues[oldKey]), | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Add new key changes (missing keys that couldn't be matched) | |
| 140 | + | for _, newKey := range unmatchedMissing { | |
| 141 | + | changes = append(changes, port.KeyChange{ | |
| 142 | + | Type: port.KeyChangeAdded, | |
| 143 | + | NewKey: newKey, | |
| 144 | + | NewValue: m.formatValue(m.defaultViper.Get(newKey)), | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Sort changes for consistent output | |
| 149 | + | sort.Slice(changes, func(i, j int) bool { | |
| 150 | + | // Sort by type first, then by key | |
| 151 | + | if changes[i].Type != changes[j].Type { | |
| 152 | + | return changes[i].Type < changes[j].Type | |
| 153 | + | } | |
| 154 | + | keyI := changes[i].NewKey | |
| 155 | + | if keyI == "" { | |
| 156 | + | keyI = changes[i].OldKey | |
| 157 | + | } | |
| 158 | + | keyJ := changes[j].NewKey | |
| 159 | + | if keyJ == "" { | |
| 160 | + | keyJ = changes[j].OldKey | |
| 161 | + | } | |
| 162 | + | return keyI < keyJ | |
| 163 | + | }) | |
| 164 | + | ||
| 165 | + | return changes, nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // findDeprecatedKeys returns user keys that don't exist in defaults. | |
| 169 | + | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | |
| 170 | + | var deprecated []string | |
| 171 | + | for userKey := range userKeys { | |
| 172 | + | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | |
| 173 | + | deprecated = append(deprecated, userKey) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | sort.Strings(deprecated) | |
| 177 | + | return deprecated | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // keyOrRelatedExistsInDefaults checks if a user key exists in defaults. | |
| 181 | + | func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool { | |
| 182 | + | if defaultKeys[userKey] { | |
| 183 | + | return true | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Check if this is a parent of any default key | |
| 187 | + | userKeyPrefix := userKey + "." | |
| 188 | + | for defaultKey := range defaultKeys { | |
| 189 | + | if strings.HasPrefix(defaultKey, userKeyPrefix) { | |
| 190 | + | return true | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Check if any parent of this user key exists | |
| 195 | + | parts := strings.Split(userKey, ".") | |
| 196 | + | for i := len(parts) - 1; i > 0; i-- { | |
| 197 | + | parentKey := strings.Join(parts[:i], ".") | |
| 198 | + | if defaultKeys[parentKey] { | |
| 199 | + | return true | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // matchRenamedKeys attempts to match deprecated keys with missing keys. | |
| 207 | + | // Returns: renames map, unmatched deprecated, unmatched missing. | |
| 208 | + | func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) { | |
| 209 | + | renames := make(map[string]string) | |
| 210 | + | usedDeprecated := make(map[string]bool) | |
| 211 | + | usedMissing := make(map[string]bool) | |
| 212 | + | ||
| 213 | + | // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color") | |
| 214 | + | for _, oldKey := range deprecated { | |
| 215 | + | for _, newKey := range missing { | |
| 216 | + | if usedMissing[newKey] { | |
| 217 | + | continue | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // Check if keys are similar (same parent path + related leaf names) | |
| 221 | + | // AND have compatible types (don't match int keys with string keys) | |
| 222 | + | if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) { | |
| 223 | + | renames[oldKey] = newKey | |
| 224 | + | usedDeprecated[oldKey] = true | |
| 225 | + | usedMissing[newKey] = true | |
| 226 | + | break | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Collect unmatched | |
| 232 | + | var unmatchedDeprecated []string | |
| 233 | + | for _, k := range deprecated { | |
| 234 | + | if !usedDeprecated[k] { | |
| 235 | + | unmatchedDeprecated = append(unmatchedDeprecated, k) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | var unmatchedMissing []string | |
| 240 | + | for _, k := range missing { | |
| 241 | + | if !usedMissing[k] { | |
| 242 | + | unmatchedMissing = append(unmatchedMissing, k) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | return renames, unmatchedDeprecated, unmatchedMissing | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // Type name constants for migration compatibility checks. | |
| 250 | + | const ( | |
| 251 | + | typeNameInt = "int" | |
| 252 | + | typeNameString = "string" | |
| 253 | + | ) | |
| 254 | + | ||
| 255 | + | // typesAreCompatible checks if the default types for two keys are compatible. | |
| 256 | + | // This prevents matching int keys with string keys during rename detection. | |
| 257 | + | func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { | |
| 258 | + | // Get the type of the new key from defaults | |
| 259 | + | newValue := m.defaultViper.Get(newKey) | |
| 260 | + | if newValue == nil { | |
| 261 | + | return false | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | newType := m.getTypeName(newValue) | |
| 265 | + | ||
| 266 | + | // Infer expected old type from key name patterns | |
| 267 | + | // Keys ending in "_width" are typically int | |
| 268 | + | // Keys ending in "_color" are typically string | |
| 269 | + | if strings.HasSuffix(oldKey, "_width") && newType == typeNameString { | |
| 270 | + | return false | |
| 271 | + | } | |
| 272 | + | if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt { | |
| 273 | + | return false | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // Additional checks based on new key patterns | |
| 277 | + | if strings.HasSuffix(newKey, "_width") && newType != typeNameInt { | |
| 278 | + | return false | |
| 279 | + | } | |
| 280 | + | if strings.HasSuffix(newKey, "_color") && newType != typeNameString { | |
| 281 | + | return false | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | return true | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // keysAreSimilar checks if two keys are likely renames of each other. | |
| 288 | + | func keysAreSimilar(oldKey, newKey string) bool { | |
| 289 | + | oldParts := strings.Split(oldKey, ".") | |
| 290 | + | newParts := strings.Split(newKey, ".") | |
| 291 | + | ||
| 292 | + | // Must have same parent path (all but last part) | |
| 293 | + | if len(oldParts) != len(newParts) { | |
| 294 | + | return false | |
| 295 | + | } | |
| 296 | + | if len(oldParts) < 2 { | |
| 297 | + | return false | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // Check parent path matches | |
| 301 | + | for i := 0; i < len(oldParts)-1; i++ { | |
| 302 | + | if oldParts[i] != newParts[i] { | |
| 303 | + | return false | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Check if leaf names are similar (one is substring of the other, or share a common base) | |
| 308 | + | oldLeaf := oldParts[len(oldParts)-1] | |
| 309 | + | newLeaf := newParts[len(newParts)-1] | |
| 310 | + | ||
| 311 | + | // Direct substring check | |
| 312 | + | if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) { | |
| 313 | + | return true | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color") | |
| 317 | + | // Extract common parts by splitting on underscore | |
| 318 | + | oldTokens := strings.Split(oldLeaf, "_") | |
| 319 | + | newTokens := strings.Split(newLeaf, "_") | |
| 320 | + | ||
| 321 | + | // Count matching tokens | |
| 322 | + | matches := 0 | |
| 323 | + | for _, ot := range oldTokens { | |
| 324 | + | for _, nt := range newTokens { | |
| 325 | + | if ot == nt { | |
| 326 | + | matches++ | |
| 327 | + | break | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // If most tokens match, consider similar | |
| 333 | + | minTokens := len(oldTokens) | |
| 334 | + | if len(newTokens) < minTokens { | |
| 335 | + | minTokens = len(newTokens) | |
| 336 | + | } | |
| 337 | + | return matches >= minTokens-1 && matches > 0 | |
| 338 | + | } | |
| 339 | + | ||
| 74 | 340 | // Migrate adds missing default keys to the user's config file. | |
| 75 | 341 | func (m *Migrator) Migrate() ([]string, error) { | |
| 76 | - | result, err := m.CheckMigration() | |
| 342 | + | changes, err := m.DetectChanges() | |
| 77 | 343 | if err != nil { | |
| 78 | 344 | return nil, err | |
| 79 | 345 | } | |
| 80 | 346 | ||
| 81 | - | if result == nil || len(result.MissingKeys) == 0 { | |
| 347 | + | if len(changes) == 0 { | |
| 82 | 348 | return nil, nil | |
| 83 | 349 | } | |
| 84 | 350 | ||
| 351 | + | configFile, err := GetConfigFile() | |
| 352 | + | if err != nil { | |
| 353 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |
| 354 | + | } | |
| 355 | + | ||
| 85 | 356 | // Create a new Viper instance to read user config | |
| 86 | 357 | userViper := viper.New() | |
| 87 | - | userViper.SetConfigFile(result.ConfigFile) | |
| 358 | + | userViper.SetConfigFile(configFile) | |
| 88 | 359 | userViper.SetConfigType("toml") | |
| 89 | 360 | ||
| 90 | 361 | // Set all defaults first | |
| @@ -96,12 +367,31 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 96 | 367 | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| 97 | 368 | } | |
| 98 | 369 | ||
| 370 | + | // Apply changes | |
| 371 | + | var appliedKeys []string | |
| 372 | + | for _, change := range changes { | |
| 373 | + | switch change.Type { | |
| 374 | + | case port.KeyChangeRenamed: | |
| 375 | + | // Copy value from old key to new key | |
| 376 | + | if userViper.IsSet(change.OldKey) { | |
| 377 | + | userViper.Set(change.NewKey, userViper.Get(change.OldKey)) | |
| 378 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) | |
| 379 | + | } | |
| 380 | + | case port.KeyChangeAdded: | |
| 381 | + | // New key is already set via defaults, just track it | |
| 382 | + | appliedKeys = append(appliedKeys, change.NewKey) | |
| 383 | + | case port.KeyChangeRemoved: | |
| 384 | + | // Nothing to do - old key will be preserved but not used | |
| 385 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey)) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 99 | 389 | // Write the merged config back | |
| 100 | 390 | if err := userViper.WriteConfig(); err != nil { | |
| 101 | 391 | return nil, fmt.Errorf("failed to write config file: %w", err) | |
| 102 | 392 | } | |
| 103 | 393 | ||
| 104 | - | return result.MissingKeys, nil | |
| 394 | + | return appliedKeys, nil | |
| 105 | 395 | } | |
| 106 | 396 | ||
| 107 | 397 | // GetKeyInfo returns detailed information about a config key. | |
| @@ -142,6 +432,20 @@ func (m *Migrator) getAllDefaultKeys() []string { | |||
| 142 | 432 | ||
| 143 | 433 | // getUserConfigKeys parses the user's TOML file and returns all defined keys. | |
| 144 | 434 | func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { | |
| 435 | + | keysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |
| 436 | + | if err != nil { | |
| 437 | + | return nil, err | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | keys := make(map[string]bool) | |
| 441 | + | for k := range keysWithValues { | |
| 442 | + | keys[k] = true | |
| 443 | + | } | |
| 444 | + | return keys, nil | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values. | |
| 448 | + | func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) { | |
| 145 | 449 | data, err := os.ReadFile(configFile) | |
| 146 | 450 | if err != nil { | |
| 147 | 451 | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| @@ -153,11 +457,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) | |||
| 153 | 457 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | |
| 154 | 458 | } | |
| 155 | 459 | ||
| 156 | - | // Flatten the map to dot-notation keys | |
| 157 | - | keys := make(map[string]bool) | |
| 158 | - | m.flattenMap(rawConfig, "", keys) | |
| 460 | + | // Flatten the map to dot-notation keys with values | |
| 461 | + | result := make(map[string]any) | |
| 462 | + | m.flattenMapWithValues(rawConfig, "", result) | |
| 159 | 463 | ||
| 160 | - | return keys, nil | |
| 464 | + | return result, nil | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values. | |
| 468 | + | func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) { | |
| 469 | + | for k, v := range data { | |
| 470 | + | key := k | |
| 471 | + | if prefix != "" { | |
| 472 | + | key = prefix + "." + k | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | switch val := v.(type) { | |
| 476 | + | case map[string]any: | |
| 477 | + | // Check if this key represents user data (like search_shortcuts, actions) | |
| 478 | + | // that should be treated as a single key rather than recursed into. | |
| 479 | + | if m.isUserDataSection(key) { | |
| 480 | + | result[key] = v | |
| 481 | + | } else { | |
| 482 | + | // Recurse into nested config sections | |
| 483 | + | m.flattenMapWithValues(val, key, result) | |
| 484 | + | } | |
| 485 | + | default: | |
| 486 | + | result[key] = v | |
| 487 | + | } | |
| 488 | + | } | |
| 161 | 489 | } | |
| 162 | 490 | ||
| 163 | 491 | // flattenMap recursively flattens a nested map to dot-notation keys. | |
| @@ -339,3 +667,29 @@ func (*Migrator) formatValue(value any) string { | |||
| 339 | 667 | return fmt.Sprintf("%v", v) | |
| 340 | 668 | } | |
| 341 | 669 | } | |
| 670 | + | ||
| 671 | + | // FormatChangesAsDiff returns changes formatted as a diff for display. | |
| 672 | + | func FormatChangesAsDiff(changes []port.KeyChange) string { | |
| 673 | + | if len(changes) == 0 { | |
| 674 | + | return "No changes detected." | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | var sb strings.Builder | |
| 678 | + | sb.WriteString("Config migration changes:\n\n") | |
| 679 | + | ||
| 680 | + | for _, change := range changes { | |
| 681 | + | switch change.Type { | |
| 682 | + | case port.KeyChangeAdded: | |
| 683 | + | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |
| 684 | + | case port.KeyChangeRemoved: | |
| 685 | + | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |
| 686 | + | case port.KeyChangeRenamed: | |
| 687 | + | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |
| 688 | + | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |
| 689 | + | case port.KeyChangeConsolidated: | |
| 690 | + | sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey)) | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | return sb.String() | |
| 695 | + | } | |
| @@ -71,20 +71,291 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) { | |||||
| 71 | }, nil | 71 | }, nil | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | + | // DetectChanges analyzes user config and returns all detected changes. | |||
| 75 | + | // This provides a detailed diff-like view of what migration would do. | |||
| 76 | + | func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||
| 77 | + | configFile, err := GetConfigFile() | |||
| 78 | + | if err != nil { | |||
| 79 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // If config file doesn't exist, no changes to detect | |||
| 83 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |||
| 84 | + | return nil, nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Get user-defined keys with values | |||
| 88 | + | userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |||
| 89 | + | if err != nil { | |||
| 90 | + | return nil, fmt.Errorf("failed to parse user config: %w", err) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Get all default keys | |||
| 94 | + | defaultKeys := m.getAllDefaultKeys() | |||
| 95 | + | defaultKeySet := make(map[string]bool) | |||
| 96 | + | for _, k := range defaultKeys { | |||
| 97 | + | defaultKeySet[k] = true | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Build user key set for lookup | |||
| 101 | + | userKeySet := make(map[string]bool) | |||
| 102 | + | for k := range userKeysWithValues { | |||
| 103 | + | userKeySet[k] = true | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | var changes []port.KeyChange | |||
| 107 | + | ||||
| 108 | + | // Find deprecated keys (in user config but not in defaults) | |||
| 109 | + | deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet) | |||
| 110 | + | ||||
| 111 | + | // Find missing keys (in defaults but not in user config) | |||
| 112 | + | missingKeys := m.findMissingKeys(defaultKeys, userKeySet) | |||
| 113 | + | ||||
| 114 | + | // Try to match deprecated keys with missing keys (detect renames) | |||
| 115 | + | renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys) | |||
| 116 | + | ||||
| 117 | + | // Add rename changes | |||
| 118 | + | for oldKey, newKey := range renames { | |||
| 119 | + | oldValue := m.formatValue(userKeysWithValues[oldKey]) | |||
| 120 | + | newValue := m.formatValue(m.defaultViper.Get(newKey)) | |||
| 121 | + | changes = append(changes, port.KeyChange{ | |||
| 122 | + | Type: port.KeyChangeRenamed, | |||
| 123 | + | OldKey: oldKey, | |||
| 124 | + | NewKey: newKey, | |||
| 125 | + | OldValue: oldValue, | |||
| 126 | + | NewValue: newValue, | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // Add removed changes (deprecated keys that couldn't be matched) | |||
| 131 | + | for _, oldKey := range unmatchedDeprecated { | |||
| 132 | + | changes = append(changes, port.KeyChange{ | |||
| 133 | + | Type: port.KeyChangeRemoved, | |||
| 134 | + | OldKey: oldKey, | |||
| 135 | + | OldValue: m.formatValue(userKeysWithValues[oldKey]), | |||
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Add new key changes (missing keys that couldn't be matched) | |||
| 140 | + | for _, newKey := range unmatchedMissing { | |||
| 141 | + | changes = append(changes, port.KeyChange{ | |||
| 142 | + | Type: port.KeyChangeAdded, | |||
| 143 | + | NewKey: newKey, | |||
| 144 | + | NewValue: m.formatValue(m.defaultViper.Get(newKey)), | |||
| 145 | + | }) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Sort changes for consistent output | |||
| 149 | + | sort.Slice(changes, func(i, j int) bool { | |||
| 150 | + | // Sort by type first, then by key | |||
| 151 | + | if changes[i].Type != changes[j].Type { | |||
| 152 | + | return changes[i].Type < changes[j].Type | |||
| 153 | + | } | |||
| 154 | + | keyI := changes[i].NewKey | |||
| 155 | + | if keyI == "" { | |||
| 156 | + | keyI = changes[i].OldKey | |||
| 157 | + | } | |||
| 158 | + | keyJ := changes[j].NewKey | |||
| 159 | + | if keyJ == "" { | |||
| 160 | + | keyJ = changes[j].OldKey | |||
| 161 | + | } | |||
| 162 | + | return keyI < keyJ | |||
| 163 | + | }) | |||
| 164 | + | ||||
| 165 | + | return changes, nil | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // findDeprecatedKeys returns user keys that don't exist in defaults. | |||
| 169 | + | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | |||
| 170 | + | var deprecated []string | |||
| 171 | + | for userKey := range userKeys { | |||
| 172 | + | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | |||
| 173 | + | deprecated = append(deprecated, userKey) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | sort.Strings(deprecated) | |||
| 177 | + | return deprecated | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // keyOrRelatedExistsInDefaults checks if a user key exists in defaults. | |||
| 181 | + | func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool { | |||
| 182 | + | if defaultKeys[userKey] { | |||
| 183 | + | return true | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Check if this is a parent of any default key | |||
| 187 | + | userKeyPrefix := userKey + "." | |||
| 188 | + | for defaultKey := range defaultKeys { | |||
| 189 | + | if strings.HasPrefix(defaultKey, userKeyPrefix) { | |||
| 190 | + | return true | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Check if any parent of this user key exists | |||
| 195 | + | parts := strings.Split(userKey, ".") | |||
| 196 | + | for i := len(parts) - 1; i > 0; i-- { | |||
| 197 | + | parentKey := strings.Join(parts[:i], ".") | |||
| 198 | + | if defaultKeys[parentKey] { | |||
| 199 | + | return true | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | return false | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // matchRenamedKeys attempts to match deprecated keys with missing keys. | |||
| 207 | + | // Returns: renames map, unmatched deprecated, unmatched missing. | |||
| 208 | + | func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) { | |||
| 209 | + | renames := make(map[string]string) | |||
| 210 | + | usedDeprecated := make(map[string]bool) | |||
| 211 | + | usedMissing := make(map[string]bool) | |||
| 212 | + | ||||
| 213 | + | // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color") | |||
| 214 | + | for _, oldKey := range deprecated { | |||
| 215 | + | for _, newKey := range missing { | |||
| 216 | + | if usedMissing[newKey] { | |||
| 217 | + | continue | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // Check if keys are similar (same parent path + related leaf names) | |||
| 221 | + | // AND have compatible types (don't match int keys with string keys) | |||
| 222 | + | if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) { | |||
| 223 | + | renames[oldKey] = newKey | |||
| 224 | + | usedDeprecated[oldKey] = true | |||
| 225 | + | usedMissing[newKey] = true | |||
| 226 | + | break | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // Collect unmatched | |||
| 232 | + | var unmatchedDeprecated []string | |||
| 233 | + | for _, k := range deprecated { | |||
| 234 | + | if !usedDeprecated[k] { | |||
| 235 | + | unmatchedDeprecated = append(unmatchedDeprecated, k) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | var unmatchedMissing []string | |||
| 240 | + | for _, k := range missing { | |||
| 241 | + | if !usedMissing[k] { | |||
| 242 | + | unmatchedMissing = append(unmatchedMissing, k) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | return renames, unmatchedDeprecated, unmatchedMissing | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Type name constants for migration compatibility checks. | |||
| 250 | + | const ( | |||
| 251 | + | typeNameInt = "int" | |||
| 252 | + | typeNameString = "string" | |||
| 253 | + | ) | |||
| 254 | + | ||||
| 255 | + | // typesAreCompatible checks if the default types for two keys are compatible. | |||
| 256 | + | // This prevents matching int keys with string keys during rename detection. | |||
| 257 | + | func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { | |||
| 258 | + | // Get the type of the new key from defaults | |||
| 259 | + | newValue := m.defaultViper.Get(newKey) | |||
| 260 | + | if newValue == nil { | |||
| 261 | + | return false | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | newType := m.getTypeName(newValue) | |||
| 265 | + | ||||
| 266 | + | // Infer expected old type from key name patterns | |||
| 267 | + | // Keys ending in "_width" are typically int | |||
| 268 | + | // Keys ending in "_color" are typically string | |||
| 269 | + | if strings.HasSuffix(oldKey, "_width") && newType == typeNameString { | |||
| 270 | + | return false | |||
| 271 | + | } | |||
| 272 | + | if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt { | |||
| 273 | + | return false | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // Additional checks based on new key patterns | |||
| 277 | + | if strings.HasSuffix(newKey, "_width") && newType != typeNameInt { | |||
| 278 | + | return false | |||
| 279 | + | } | |||
| 280 | + | if strings.HasSuffix(newKey, "_color") && newType != typeNameString { | |||
| 281 | + | return false | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | return true | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // keysAreSimilar checks if two keys are likely renames of each other. | |||
| 288 | + | func keysAreSimilar(oldKey, newKey string) bool { | |||
| 289 | + | oldParts := strings.Split(oldKey, ".") | |||
| 290 | + | newParts := strings.Split(newKey, ".") | |||
| 291 | + | ||||
| 292 | + | // Must have same parent path (all but last part) | |||
| 293 | + | if len(oldParts) != len(newParts) { | |||
| 294 | + | return false | |||
| 295 | + | } | |||
| 296 | + | if len(oldParts) < 2 { | |||
| 297 | + | return false | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | // Check parent path matches | |||
| 301 | + | for i := 0; i < len(oldParts)-1; i++ { | |||
| 302 | + | if oldParts[i] != newParts[i] { | |||
| 303 | + | return false | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // Check if leaf names are similar (one is substring of the other, or share a common base) | |||
| 308 | + | oldLeaf := oldParts[len(oldParts)-1] | |||
| 309 | + | newLeaf := newParts[len(newParts)-1] | |||
| 310 | + | ||||
| 311 | + | // Direct substring check | |||
| 312 | + | if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) { | |||
| 313 | + | return true | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color") | |||
| 317 | + | // Extract common parts by splitting on underscore | |||
| 318 | + | oldTokens := strings.Split(oldLeaf, "_") | |||
| 319 | + | newTokens := strings.Split(newLeaf, "_") | |||
| 320 | + | ||||
| 321 | + | // Count matching tokens | |||
| 322 | + | matches := 0 | |||
| 323 | + | for _, ot := range oldTokens { | |||
| 324 | + | for _, nt := range newTokens { | |||
| 325 | + | if ot == nt { | |||
| 326 | + | matches++ | |||
| 327 | + | break | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // If most tokens match, consider similar | |||
| 333 | + | minTokens := len(oldTokens) | |||
| 334 | + | if len(newTokens) < minTokens { | |||
| 335 | + | minTokens = len(newTokens) | |||
| 336 | + | } | |||
| 337 | + | return matches >= minTokens-1 && matches > 0 | |||
| 338 | + | } | |||
| 339 | + | ||||
| 74 | // Migrate adds missing default keys to the user's config file. | 340 | // Migrate adds missing default keys to the user's config file. | ||
| 75 | func (m *Migrator) Migrate() ([]string, error) { | 341 | func (m *Migrator) Migrate() ([]string, error) { | ||
| 76 | - | result, err := m.CheckMigration() | 342 | + | changes, err := m.DetectChanges() |
| 77 | if err != nil { | 343 | if err != nil { | ||
| 78 | return nil, err | 344 | return nil, err | ||
| 79 | } | 345 | } | ||
| 80 | 346 | ||||
| 81 | - | if result == nil || len(result.MissingKeys) == 0 { | 347 | + | if len(changes) == 0 { |
| 82 | return nil, nil | 348 | return nil, nil | ||
| 83 | } | 349 | } | ||
| 84 | 350 | ||||
| 351 | + | configFile, err := GetConfigFile() | |||
| 352 | + | if err != nil { | |||
| 353 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 85 | // Create a new Viper instance to read user config | 356 | // Create a new Viper instance to read user config | ||
| 86 | userViper := viper.New() | 357 | userViper := viper.New() | ||
| 87 | - | userViper.SetConfigFile(result.ConfigFile) | 358 | + | userViper.SetConfigFile(configFile) |
| 88 | userViper.SetConfigType("toml") | 359 | userViper.SetConfigType("toml") | ||
| 89 | 360 | ||||
| 90 | // Set all defaults first | 361 | // Set all defaults first | ||
| @@ -96,12 +367,31 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 96 | return nil, fmt.Errorf("failed to read config file: %w", err) | 367 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| 97 | } | 368 | } | ||
| 98 | 369 | ||||
| 370 | + | // Apply changes | |||
| 371 | + | var appliedKeys []string | |||
| 372 | + | for _, change := range changes { | |||
| 373 | + | switch change.Type { | |||
| 374 | + | case port.KeyChangeRenamed: | |||
| 375 | + | // Copy value from old key to new key | |||
| 376 | + | if userViper.IsSet(change.OldKey) { | |||
| 377 | + | userViper.Set(change.NewKey, userViper.Get(change.OldKey)) | |||
| 378 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) | |||
| 379 | + | } | |||
| 380 | + | case port.KeyChangeAdded: | |||
| 381 | + | // New key is already set via defaults, just track it | |||
| 382 | + | appliedKeys = append(appliedKeys, change.NewKey) | |||
| 383 | + | case port.KeyChangeRemoved: | |||
| 384 | + | // Nothing to do - old key will be preserved but not used | |||
| 385 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey)) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 99 | // Write the merged config back | 389 | // Write the merged config back | ||
| 100 | if err := userViper.WriteConfig(); err != nil { | 390 | if err := userViper.WriteConfig(); err != nil { | ||
| 101 | return nil, fmt.Errorf("failed to write config file: %w", err) | 391 | return nil, fmt.Errorf("failed to write config file: %w", err) | ||
| 102 | } | 392 | } | ||
| 103 | 393 | ||||
| 104 | - | return result.MissingKeys, nil | 394 | + | return appliedKeys, nil |
| 105 | } | 395 | } | ||
| 106 | 396 | ||||
| 107 | // GetKeyInfo returns detailed information about a config key. | 397 | // GetKeyInfo returns detailed information about a config key. | ||
| @@ -142,6 +432,20 @@ func (m *Migrator) getAllDefaultKeys() []string { | |||||
| 142 | 432 | ||||
| 143 | // getUserConfigKeys parses the user's TOML file and returns all defined keys. | 433 | // getUserConfigKeys parses the user's TOML file and returns all defined keys. | ||
| 144 | func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { | 434 | func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { | ||
| 435 | + | keysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |||
| 436 | + | if err != nil { | |||
| 437 | + | return nil, err | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | keys := make(map[string]bool) | |||
| 441 | + | for k := range keysWithValues { | |||
| 442 | + | keys[k] = true | |||
| 443 | + | } | |||
| 444 | + | return keys, nil | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values. | |||
| 448 | + | func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) { | |||
| 145 | data, err := os.ReadFile(configFile) | 449 | data, err := os.ReadFile(configFile) | ||
| 146 | if err != nil { | 450 | if err != nil { | ||
| 147 | return nil, fmt.Errorf("failed to read config file: %w", err) | 451 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| @@ -153,11 +457,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) | |||||
| 153 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | 457 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | ||
| 154 | } | 458 | } | ||
| 155 | 459 | ||||
| 156 | - | // Flatten the map to dot-notation keys | 460 | + | // Flatten the map to dot-notation keys with values |
| 157 | - | keys := make(map[string]bool) | 461 | + | result := make(map[string]any) |
| 158 | - | m.flattenMap(rawConfig, "", keys) | 462 | + | m.flattenMapWithValues(rawConfig, "", result) |
| 159 | 463 | ||||
| 160 | - | return keys, nil | 464 | + | return result, nil |
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values. | |||
| 468 | + | func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) { | |||
| 469 | + | for k, v := range data { | |||
| 470 | + | key := k | |||
| 471 | + | if prefix != "" { | |||
| 472 | + | key = prefix + "." + k | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | switch val := v.(type) { | |||
| 476 | + | case map[string]any: | |||
| 477 | + | // Check if this key represents user data (like search_shortcuts, actions) | |||
| 478 | + | // that should be treated as a single key rather than recursed into. | |||
| 479 | + | if m.isUserDataSection(key) { | |||
| 480 | + | result[key] = v | |||
| 481 | + | } else { | |||
| 482 | + | // Recurse into nested config sections | |||
| 483 | + | m.flattenMapWithValues(val, key, result) | |||
| 484 | + | } | |||
| 485 | + | default: | |||
| 486 | + | result[key] = v | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 161 | } | 489 | } | ||
| 162 | 490 | ||||
| 163 | // flattenMap recursively flattens a nested map to dot-notation keys. | 491 | // flattenMap recursively flattens a nested map to dot-notation keys. | ||
| @@ -339,3 +667,29 @@ func (*Migrator) formatValue(value any) string { | |||||
| 339 | return fmt.Sprintf("%v", v) | 667 | return fmt.Sprintf("%v", v) | ||
| 340 | } | 668 | } | ||
| 341 | } | 669 | } | ||
| 670 | + | ||||
| 671 | + | // FormatChangesAsDiff returns changes formatted as a diff for display. | |||
| 672 | + | func FormatChangesAsDiff(changes []port.KeyChange) string { | |||
| 673 | + | if len(changes) == 0 { | |||
| 674 | + | return "No changes detected." | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | var sb strings.Builder | |||
| 678 | + | sb.WriteString("Config migration changes:\n\n") | |||
| 679 | + | ||||
| 680 | + | for _, change := range changes { | |||
| 681 | + | switch change.Type { | |||
| 682 | + | case port.KeyChangeAdded: | |||
| 683 | + | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |||
| 684 | + | case port.KeyChangeRemoved: | |||
| 685 | + | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |||
| 686 | + | case port.KeyChangeRenamed: | |||
| 687 | + | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |||
| 688 | + | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |||
| 689 | + | case port.KeyChangeConsolidated: | |||
| 690 | + | sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey)) | |||
| 691 | + | } | |||
| 692 | + | } | |||
| 693 | + | ||||
| 694 | + | return sb.String() | |||
| 695 | + | } | |||
M internal/infrastructure/config/schema.go +15 -23
| @@ -444,29 +444,21 @@ type WorkspaceStylingConfig struct { | |||
| 444 | 444 | // BorderColor for focused panes (CSS color value or theme variable) | |
| 445 | 445 | BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"` | |
| 446 | 446 | ||
| 447 | - | // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay) | |
| 448 | - | PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 449 | - | // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable) | |
| 450 | - | // Defaults to "#4A90E2" (blue) if not set | |
| 451 | - | PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 452 | - | ||
| 453 | - | // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay) | |
| 454 | - | TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 455 | - | // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable) | |
| 456 | - | // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor | |
| 457 | - | TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 458 | - | ||
| 459 | - | // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay) | |
| 460 | - | SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 461 | - | // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable) | |
| 462 | - | // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors | |
| 463 | - | SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 464 | - | ||
| 465 | - | // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay) | |
| 466 | - | ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 467 | - | // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable) | |
| 468 | - | // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors | |
| 469 | - | ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 447 | + | // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize) | |
| 448 | + | ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 449 | + | ||
| 450 | + | // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue) | |
| 451 | + | PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 452 | + | // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange) | |
| 453 | + | TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 454 | + | // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple) | |
| 455 | + | SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 456 | + | // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal) | |
| 457 | + | ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 458 | + | ||
| 459 | + | // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active. | |
| 460 | + | // Default: true | |
| 461 | + | ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line | |
| 470 | 462 | ||
| 471 | 463 | // TransitionDuration in milliseconds for border animations | |
| 472 | 464 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line | |
| @@ -444,29 +444,21 @@ type WorkspaceStylingConfig struct { | |||||
| 444 | // BorderColor for focused panes (CSS color value or theme variable) | 444 | // BorderColor for focused panes (CSS color value or theme variable) | ||
| 445 | BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"` | 445 | BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"` | ||
| 446 | 446 | ||||
| 447 | - | // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay) | 447 | + | // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize) |
| 448 | - | PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"` //nolint:lll // struct tags must stay on one line | 448 | + | ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line |
| 449 | - | // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable) | 449 | + | |
| 450 | - | // Defaults to "#4A90E2" (blue) if not set | 450 | + | // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue) |
| 451 | - | PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"` //nolint:lll // struct tags must stay on one line | 451 | + | PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line |
| 452 | - | 452 | + | // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange) | |
| 453 | - | // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay) | 453 | + | TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line |
| 454 | - | TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"` //nolint:lll // struct tags must stay on one line | 454 | + | // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple) |
| 455 | - | // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable) | 455 | + | SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line |
| 456 | - | // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor | 456 | + | // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal) |
| 457 | - | TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line | 457 | + | ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line |
| 458 | - | 458 | + | ||
| 459 | - | // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay) | 459 | + | // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active. |
| 460 | - | SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line | 460 | + | // Default: true |
| 461 | - | // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable) | 461 | + | ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line |
| 462 | - | // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors | |||
| 463 | - | SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line | |||
| 464 | - | ||||
| 465 | - | // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay) | |||
| 466 | - | ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line | |||
| 467 | - | // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable) | |||
| 468 | - | // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors | |||
| 469 | - | ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line | |||
| 470 | 462 | ||||
| 471 | // TransitionDuration in milliseconds for border animations | 463 | // TransitionDuration in milliseconds for border animations | ||
| 472 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line | 464 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line | ||
M internal/infrastructure/config/validation.go +2 -5
| @@ -146,11 +146,8 @@ func validateWorkspaceStyling(config *Config) []string { | |||
| 146 | 146 | if config.Workspace.Styling.BorderWidth < 0 { | |
| 147 | 147 | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | |
| 148 | 148 | } | |
| 149 | - | if config.Workspace.Styling.PaneModeBorderWidth < 0 { | |
| 150 | - | validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative") | |
| 151 | - | } | |
| 152 | - | if config.Workspace.Styling.TabModeBorderWidth < 0 { | |
| 153 | - | validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative") | |
| 149 | + | if config.Workspace.Styling.ModeBorderWidth < 0 { | |
| 150 | + | validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative") | |
| 154 | 151 | } | |
| 155 | 152 | if config.Workspace.Styling.TransitionDuration < 0 { | |
| 156 | 153 | validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") | |
| @@ -146,11 +146,8 @@ func validateWorkspaceStyling(config *Config) []string { | |||||
| 146 | if config.Workspace.Styling.BorderWidth < 0 { | 146 | if config.Workspace.Styling.BorderWidth < 0 { | ||
| 147 | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | 147 | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | ||
| 148 | } | 148 | } | ||
| 149 | - | if config.Workspace.Styling.PaneModeBorderWidth < 0 { | 149 | + | if config.Workspace.Styling.ModeBorderWidth < 0 { |
| 150 | - | validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative") | 150 | + | validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative") |
| 151 | - | } | |||
| 152 | - | if config.Workspace.Styling.TabModeBorderWidth < 0 { | |||
| 153 | - | validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative") | |||
| 154 | } | 151 | } | ||
| 155 | if config.Workspace.Styling.TransitionDuration < 0 { | 152 | if config.Workspace.Styling.TransitionDuration < 0 { | ||
| 156 | validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") | 153 | validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") | ||
M internal/ui/app.go +62 -0
| @@ -87,6 +87,8 @@ type App struct { | |||
| 87 | 87 | ||
| 88 | 88 | // App-level toaster for system notifications (filter status, etc.) | |
| 89 | 89 | appToaster *component.Toaster | |
| 90 | + | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | |
| 91 | + | modeToaster *component.Toaster | |
| 90 | 92 | ||
| 91 | 93 | // Session management | |
| 92 | 94 | sessionManager *component.SessionManager | |
| @@ -302,6 +304,18 @@ func (a *App) initAppToasterOverlay() { | |||
| 302 | 304 | return | |
| 303 | 305 | } | |
| 304 | 306 | a.mainWindow.AddOverlay(gtkWidget) | |
| 307 | + | ||
| 308 | + | // Create mode indicator toaster for modal mode notifications. | |
| 309 | + | a.modeToaster = component.NewToaster(a.widgetFactory) | |
| 310 | + | modeToasterWidget := a.modeToaster.Widget() | |
| 311 | + | if modeToasterWidget == nil { | |
| 312 | + | return | |
| 313 | + | } | |
| 314 | + | modeGtkWidget := modeToasterWidget.GtkWidget() | |
| 315 | + | if modeGtkWidget == nil { | |
| 316 | + | return | |
| 317 | + | } | |
| 318 | + | a.mainWindow.AddOverlay(modeGtkWidget) | |
| 305 | 319 | } | |
| 306 | 320 | ||
| 307 | 321 | func (a *App) initFocusAndBorderOverlay() { | |
| @@ -1270,6 +1284,54 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 1270 | 1284 | if a.borderMgr != nil { | |
| 1271 | 1285 | a.borderMgr.OnModeChange(ctx, from, to) | |
| 1272 | 1286 | } | |
| 1287 | + | ||
| 1288 | + | // Show/hide mode indicator toaster based on config. | |
| 1289 | + | a.updateModeIndicatorToaster(ctx, to) | |
| 1290 | + | } | |
| 1291 | + | ||
| 1292 | + | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | |
| 1293 | + | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | |
| 1294 | + | if a.modeToaster == nil { | |
| 1295 | + | return | |
| 1296 | + | } | |
| 1297 | + | ||
| 1298 | + | // Check if mode indicator toaster is enabled in config. | |
| 1299 | + | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | |
| 1300 | + | a.modeToaster.Hide() | |
| 1301 | + | return | |
| 1302 | + | } | |
| 1303 | + | ||
| 1304 | + | if mode == input.ModeNormal { | |
| 1305 | + | a.modeToaster.Hide() | |
| 1306 | + | return | |
| 1307 | + | } | |
| 1308 | + | ||
| 1309 | + | // Show persistent toaster at bottom-left with mode display name. | |
| 1310 | + | // Note: Call Show() first, then ApplyModeClass() because Show() clears custom styles. | |
| 1311 | + | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 1312 | + | component.WithDuration(0), // Persistent until mode exits. | |
| 1313 | + | component.WithPosition(component.ToastPositionBottomLeft), | |
| 1314 | + | ) | |
| 1315 | + | ||
| 1316 | + | // Apply mode-specific CSS class after Show() to override the default level styling. | |
| 1317 | + | modeClass := getModeToastClass(mode) | |
| 1318 | + | a.modeToaster.ApplyModeClass(modeClass) | |
| 1319 | + | } | |
| 1320 | + | ||
| 1321 | + | // getModeToastClass returns the CSS class for the given mode's toast styling. | |
| 1322 | + | func getModeToastClass(mode input.Mode) string { | |
| 1323 | + | switch mode { | |
| 1324 | + | case input.ModePane: | |
| 1325 | + | return "toast-pane-mode" | |
| 1326 | + | case input.ModeTab: | |
| 1327 | + | return "toast-tab-mode" | |
| 1328 | + | case input.ModeSession: | |
| 1329 | + | return "toast-session-mode" | |
| 1330 | + | case input.ModeResize: | |
| 1331 | + | return "toast-resize-mode" | |
| 1332 | + | default: | |
| 1333 | + | return "" | |
| 1334 | + | } | |
| 1273 | 1335 | } | |
| 1274 | 1336 | ||
| 1275 | 1337 | func (a *App) clearResizeModeBorder() { | |
| @@ -87,6 +87,8 @@ type App struct { | |||||
| 87 | 87 | ||||
| 88 | // App-level toaster for system notifications (filter status, etc.) | 88 | // App-level toaster for system notifications (filter status, etc.) | ||
| 89 | appToaster *component.Toaster | 89 | appToaster *component.Toaster | ||
| 90 | + | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | |||
| 91 | + | modeToaster *component.Toaster | |||
| 90 | 92 | ||||
| 91 | // Session management | 93 | // Session management | ||
| 92 | sessionManager *component.SessionManager | 94 | sessionManager *component.SessionManager | ||
| @@ -302,6 +304,18 @@ func (a *App) initAppToasterOverlay() { | |||||
| 302 | return | 304 | return | ||
| 303 | } | 305 | } | ||
| 304 | a.mainWindow.AddOverlay(gtkWidget) | 306 | a.mainWindow.AddOverlay(gtkWidget) | ||
| 307 | + | ||||
| 308 | + | // Create mode indicator toaster for modal mode notifications. | |||
| 309 | + | a.modeToaster = component.NewToaster(a.widgetFactory) | |||
| 310 | + | modeToasterWidget := a.modeToaster.Widget() | |||
| 311 | + | if modeToasterWidget == nil { | |||
| 312 | + | return | |||
| 313 | + | } | |||
| 314 | + | modeGtkWidget := modeToasterWidget.GtkWidget() | |||
| 315 | + | if modeGtkWidget == nil { | |||
| 316 | + | return | |||
| 317 | + | } | |||
| 318 | + | a.mainWindow.AddOverlay(modeGtkWidget) | |||
| 305 | } | 319 | } | ||
| 306 | 320 | ||||
| 307 | func (a *App) initFocusAndBorderOverlay() { | 321 | func (a *App) initFocusAndBorderOverlay() { | ||
| @@ -1270,6 +1284,54 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||||
| 1270 | if a.borderMgr != nil { | 1284 | if a.borderMgr != nil { | ||
| 1271 | a.borderMgr.OnModeChange(ctx, from, to) | 1285 | a.borderMgr.OnModeChange(ctx, from, to) | ||
| 1272 | } | 1286 | } | ||
| 1287 | + | ||||
| 1288 | + | // Show/hide mode indicator toaster based on config. | |||
| 1289 | + | a.updateModeIndicatorToaster(ctx, to) | |||
| 1290 | + | } | |||
| 1291 | + | ||||
| 1292 | + | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | |||
| 1293 | + | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | |||
| 1294 | + | if a.modeToaster == nil { | |||
| 1295 | + | return | |||
| 1296 | + | } | |||
| 1297 | + | ||||
| 1298 | + | // Check if mode indicator toaster is enabled in config. | |||
| 1299 | + | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | |||
| 1300 | + | a.modeToaster.Hide() | |||
| 1301 | + | return | |||
| 1302 | + | } | |||
| 1303 | + | ||||
| 1304 | + | if mode == input.ModeNormal { | |||
| 1305 | + | a.modeToaster.Hide() | |||
| 1306 | + | return | |||
| 1307 | + | } | |||
| 1308 | + | ||||
| 1309 | + | // Show persistent toaster at bottom-left with mode display name. | |||
| 1310 | + | // Note: Call Show() first, then ApplyModeClass() because Show() clears custom styles. | |||
| 1311 | + | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |||
| 1312 | + | component.WithDuration(0), // Persistent until mode exits. | |||
| 1313 | + | component.WithPosition(component.ToastPositionBottomLeft), | |||
| 1314 | + | ) | |||
| 1315 | + | ||||
| 1316 | + | // Apply mode-specific CSS class after Show() to override the default level styling. | |||
| 1317 | + | modeClass := getModeToastClass(mode) | |||
| 1318 | + | a.modeToaster.ApplyModeClass(modeClass) | |||
| 1319 | + | } | |||
| 1320 | + | ||||
| 1321 | + | // getModeToastClass returns the CSS class for the given mode's toast styling. | |||
| 1322 | + | func getModeToastClass(mode input.Mode) string { | |||
| 1323 | + | switch mode { | |||
| 1324 | + | case input.ModePane: | |||
| 1325 | + | return "toast-pane-mode" | |||
| 1326 | + | case input.ModeTab: | |||
| 1327 | + | return "toast-tab-mode" | |||
| 1328 | + | case input.ModeSession: | |||
| 1329 | + | return "toast-session-mode" | |||
| 1330 | + | case input.ModeResize: | |||
| 1331 | + | return "toast-resize-mode" | |||
| 1332 | + | default: | |||
| 1333 | + | return "" | |||
| 1334 | + | } | |||
| 1273 | } | 1335 | } | ||
| 1274 | 1336 | ||||
| 1275 | func (a *App) clearResizeModeBorder() { | 1337 | func (a *App) clearResizeModeBorder() { | ||
M internal/ui/component/toaster.go +200 -8
| @@ -48,6 +48,78 @@ func (l ToastLevel) cssClass() string { | |||
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | // ToastPosition defines where the toast appears on screen. | |
| 52 | + | type ToastPosition int | |
| 53 | + | ||
| 54 | + | const ( | |
| 55 | + | // ToastPositionTopLeft positions toast in top-left corner. | |
| 56 | + | ToastPositionTopLeft ToastPosition = iota | |
| 57 | + | // ToastPositionTopCenter positions toast at top center. | |
| 58 | + | ToastPositionTopCenter | |
| 59 | + | // ToastPositionTopRight positions toast in top-right corner. | |
| 60 | + | ToastPositionTopRight | |
| 61 | + | // ToastPositionBottomLeft positions toast in bottom-left corner. | |
| 62 | + | ToastPositionBottomLeft | |
| 63 | + | // ToastPositionBottomCenter positions toast at bottom center. | |
| 64 | + | ToastPositionBottomCenter | |
| 65 | + | // ToastPositionBottomRight positions toast in bottom-right corner. | |
| 66 | + | ToastPositionBottomRight | |
| 67 | + | ) | |
| 68 | + | ||
| 69 | + | // ToastOptions configures toast appearance and behavior. | |
| 70 | + | type ToastOptions struct { | |
| 71 | + | // Duration in milliseconds. 0 = persistent (no auto-dismiss), >0 = auto-dismiss after duration. | |
| 72 | + | Duration int | |
| 73 | + | // BackgroundColor overrides the default background color (CSS color value). | |
| 74 | + | // Empty string uses the level's default color. | |
| 75 | + | BackgroundColor string | |
| 76 | + | // TextColor overrides the default text color (CSS color value). | |
| 77 | + | // Empty string uses the default (auto-contrast with background). | |
| 78 | + | TextColor string | |
| 79 | + | // Position determines where the toast appears on screen. | |
| 80 | + | Position ToastPosition | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // ToastOption is a functional option for configuring toast display. | |
| 84 | + | type ToastOption func(*ToastOptions) | |
| 85 | + | ||
| 86 | + | // WithDuration sets the auto-dismiss duration in milliseconds. | |
| 87 | + | // Use 0 for persistent toasts that require manual dismissal. | |
| 88 | + | func WithDuration(ms int) ToastOption { | |
| 89 | + | return func(o *ToastOptions) { | |
| 90 | + | o.Duration = ms | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // WithBackgroundColor sets a custom background color (CSS color value). | |
| 95 | + | func WithBackgroundColor(color string) ToastOption { | |
| 96 | + | return func(o *ToastOptions) { | |
| 97 | + | o.BackgroundColor = color | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // WithTextColor sets a custom text color (CSS color value). | |
| 102 | + | func WithTextColor(color string) ToastOption { | |
| 103 | + | return func(o *ToastOptions) { | |
| 104 | + | o.TextColor = color | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // WithPosition sets the toast position on screen. | |
| 109 | + | func WithPosition(pos ToastPosition) ToastOption { | |
| 110 | + | return func(o *ToastOptions) { | |
| 111 | + | o.Position = pos | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // defaultToastOptions returns the default toast options. | |
| 116 | + | func defaultToastOptions() ToastOptions { | |
| 117 | + | return ToastOptions{ | |
| 118 | + | Duration: toastDismissTimeoutMs, | |
| 119 | + | Position: ToastPositionTopLeft, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 51 | 123 | // Toaster displays toast notifications in an overlay. | |
| 52 | 124 | // It supports different notification levels and auto-dismissal. | |
| 53 | 125 | // When a new toast is shown while one is already visible, the text | |
| @@ -60,18 +132,24 @@ type Toaster struct { | |||
| 60 | 132 | visible bool | |
| 61 | 133 | dismissTimer uint // GLib timer source ID | |
| 62 | 134 | ||
| 135 | + | // Track current options for cleanup | |
| 136 | + | currentOpts ToastOptions | |
| 137 | + | hasCustomStyle bool | |
| 138 | + | customBgColor string // Current custom background color | |
| 139 | + | customTextColor string // Current custom text color | |
| 140 | + | ||
| 63 | 141 | mu sync.Mutex | |
| 64 | 142 | } | |
| 65 | 143 | ||
| 66 | 144 | // NewToaster creates a new toaster component for overlay display. | |
| 67 | - | // The toaster is positioned in the top-left corner with margin. | |
| 145 | + | // The toaster is positioned in the top-left corner with margin by default. | |
| 68 | 146 | func NewToaster(factory layout.WidgetFactory) *Toaster { | |
| 69 | 147 | // Create container box for styling and positioning | |
| 70 | 148 | container := factory.NewBox(layout.OrientationHorizontal, 0) | |
| 71 | 149 | container.AddCssClass("toast") | |
| 72 | 150 | container.AddCssClass("toast-info") // Default level | |
| 73 | 151 | ||
| 74 | - | // Position in top-left with margin | |
| 152 | + | // Position in top-left with margin (default) | |
| 75 | 153 | container.SetHalign(gtk.AlignStartValue) | |
| 76 | 154 | container.SetValign(gtk.AlignStartValue) | |
| 77 | 155 | ||
| @@ -98,17 +176,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster { | |||
| 98 | 176 | label: label, | |
| 99 | 177 | currentLevel: ToastInfo, | |
| 100 | 178 | visible: false, | |
| 179 | + | currentOpts: defaultToastOptions(), | |
| 101 | 180 | } | |
| 102 | 181 | } | |
| 103 | 182 | ||
| 104 | 183 | // Show displays a toast notification with the given message and level. | |
| 105 | 184 | // If a toast is already visible, updates the text and resets the dismiss timer. | |
| 106 | - | func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |
| 185 | + | // Optional ToastOption arguments can customize duration, colors, and position. | |
| 186 | + | func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) { | |
| 107 | 187 | log := logging.FromContext(ctx) | |
| 108 | 188 | ||
| 189 | + | // Build options from defaults and overrides | |
| 190 | + | options := defaultToastOptions() | |
| 191 | + | for _, opt := range opts { | |
| 192 | + | opt(&options) | |
| 193 | + | } | |
| 194 | + | ||
| 109 | 195 | t.mu.Lock() | |
| 110 | 196 | defer t.mu.Unlock() | |
| 111 | 197 | ||
| 198 | + | // Clear previous custom styles if any | |
| 199 | + | t.clearCustomStyle() | |
| 200 | + | ||
| 112 | 201 | // Update level CSS class if changed | |
| 113 | 202 | if t.currentLevel != level { | |
| 114 | 203 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| @@ -116,6 +205,12 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||
| 116 | 205 | t.currentLevel = level | |
| 117 | 206 | } | |
| 118 | 207 | ||
| 208 | + | // Apply custom colors if specified | |
| 209 | + | t.applyCustomStyle(options) | |
| 210 | + | ||
| 211 | + | // Apply position | |
| 212 | + | t.applyPosition(options.Position) | |
| 213 | + | ||
| 119 | 214 | // Update message text | |
| 120 | 215 | t.label.SetText(message) | |
| 121 | 216 | ||
| @@ -131,12 +226,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||
| 131 | 226 | t.container.SetVisible(true) | |
| 132 | 227 | } | |
| 133 | 228 | ||
| 134 | - | // Start new dismiss timer | |
| 135 | - | t.startDismissTimer(ctx) | |
| 229 | + | // Start new dismiss timer (if duration > 0) | |
| 230 | + | if options.Duration > 0 { | |
| 231 | + | t.startDismissTimerWithDuration(ctx, options.Duration) | |
| 232 | + | } | |
| 233 | + | // Duration == 0 means persistent, no timer | |
| 234 | + | ||
| 235 | + | t.currentOpts = options | |
| 136 | 236 | ||
| 137 | 237 | log.Debug(). | |
| 138 | 238 | Str("toast_message", message). | |
| 139 | 239 | Int("toast_level", int(level)). | |
| 240 | + | Int("duration", options.Duration). | |
| 140 | 241 | Msg("toast shown") | |
| 141 | 242 | } | |
| 142 | 243 | ||
| @@ -167,13 +268,104 @@ func (t *Toaster) hide() { | |||
| 167 | 268 | t.dismissTimer = 0 | |
| 168 | 269 | } | |
| 169 | 270 | ||
| 271 | + | // Clear custom styles | |
| 272 | + | t.clearCustomStyle() | |
| 273 | + | ||
| 170 | 274 | t.visible = false | |
| 171 | 275 | t.container.SetVisible(false) | |
| 172 | 276 | } | |
| 173 | 277 | ||
| 174 | - | // startDismissTimer starts the auto-dismiss timer. | |
| 278 | + | // applyPosition sets the widget alignment based on the position. | |
| 279 | + | func (t *Toaster) applyPosition(pos ToastPosition) { | |
| 280 | + | var halign, valign gtk.Align | |
| 281 | + | ||
| 282 | + | switch pos { | |
| 283 | + | case ToastPositionTopLeft: | |
| 284 | + | halign = gtk.AlignStartValue | |
| 285 | + | valign = gtk.AlignStartValue | |
| 286 | + | case ToastPositionTopCenter: | |
| 287 | + | halign = gtk.AlignCenterValue | |
| 288 | + | valign = gtk.AlignStartValue | |
| 289 | + | case ToastPositionTopRight: | |
| 290 | + | halign = gtk.AlignEndValue | |
| 291 | + | valign = gtk.AlignStartValue | |
| 292 | + | case ToastPositionBottomLeft: | |
| 293 | + | halign = gtk.AlignStartValue | |
| 294 | + | valign = gtk.AlignEndValue | |
| 295 | + | case ToastPositionBottomCenter: | |
| 296 | + | halign = gtk.AlignCenterValue | |
| 297 | + | valign = gtk.AlignEndValue | |
| 298 | + | case ToastPositionBottomRight: | |
| 299 | + | halign = gtk.AlignEndValue | |
| 300 | + | valign = gtk.AlignEndValue | |
| 301 | + | default: | |
| 302 | + | halign = gtk.AlignStartValue | |
| 303 | + | valign = gtk.AlignStartValue | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | t.container.SetHalign(halign) | |
| 307 | + | t.container.SetValign(valign) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // applyCustomStyle applies custom background and text colors. | |
| 311 | + | // Uses a CSS class that is styled dynamically by the theme manager. | |
| 312 | + | func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |
| 313 | + | if opts.BackgroundColor == "" && opts.TextColor == "" { | |
| 314 | + | return | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // Add custom class to indicate custom styling is applied | |
| 318 | + | t.container.AddCssClass("toast-custom") | |
| 319 | + | t.hasCustomStyle = true | |
| 320 | + | ||
| 321 | + | // Store the colors for access by theme/CSS generation | |
| 322 | + | t.customBgColor = opts.BackgroundColor | |
| 323 | + | t.customTextColor = opts.TextColor | |
| 324 | + | ||
| 325 | + | // Remove level-based styling when using custom colors | |
| 326 | + | if opts.BackgroundColor != "" { | |
| 327 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // clearCustomStyle removes any custom styling applied. | |
| 332 | + | func (t *Toaster) clearCustomStyle() { | |
| 333 | + | if t.hasCustomStyle { | |
| 334 | + | t.container.RemoveCssClass("toast-custom") | |
| 335 | + | // Remove any mode-specific classes | |
| 336 | + | t.container.RemoveCssClass("toast-pane-mode") | |
| 337 | + | t.container.RemoveCssClass("toast-tab-mode") | |
| 338 | + | t.container.RemoveCssClass("toast-session-mode") | |
| 339 | + | t.container.RemoveCssClass("toast-resize-mode") | |
| 340 | + | // Re-add level class if it was removed | |
| 341 | + | if t.customBgColor != "" { | |
| 342 | + | t.container.AddCssClass(t.currentLevel.cssClass()) | |
| 343 | + | } | |
| 344 | + | t.hasCustomStyle = false | |
| 345 | + | t.customBgColor = "" | |
| 346 | + | t.customTextColor = "" | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // ApplyModeClass applies a mode-specific CSS class for styling. | |
| 351 | + | // This uses the pre-defined CSS classes that reference CSS variables. | |
| 352 | + | func (t *Toaster) ApplyModeClass(modeClass string) { | |
| 353 | + | t.mu.Lock() | |
| 354 | + | defer t.mu.Unlock() | |
| 355 | + | ||
| 356 | + | // Clear any existing custom style first | |
| 357 | + | t.clearCustomStyle() | |
| 358 | + | ||
| 359 | + | // Remove level class and add mode class | |
| 360 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| 361 | + | t.container.AddCssClass(modeClass) | |
| 362 | + | t.hasCustomStyle = true | |
| 363 | + | t.customBgColor = modeClass // Store class name for cleanup tracking | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration. | |
| 175 | 367 | // Must be called with lock held. | |
| 176 | - | func (t *Toaster) startDismissTimer(ctx context.Context) { | |
| 368 | + | func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) { | |
| 177 | 369 | log := logging.FromContext(ctx) | |
| 178 | 370 | ||
| 179 | 371 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| @@ -188,7 +380,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) { | |||
| 188 | 380 | return false // Don't repeat | |
| 189 | 381 | }) | |
| 190 | 382 | ||
| 191 | - | t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0) | |
| 383 | + | t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0) | |
| 192 | 384 | } | |
| 193 | 385 | ||
| 194 | 386 | // Widget returns the underlying widget for embedding in overlays. | |
| @@ -48,6 +48,78 @@ func (l ToastLevel) cssClass() string { | |||||
| 48 | } | 48 | } | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | + | // ToastPosition defines where the toast appears on screen. | |||
| 52 | + | type ToastPosition int | |||
| 53 | + | ||||
| 54 | + | const ( | |||
| 55 | + | // ToastPositionTopLeft positions toast in top-left corner. | |||
| 56 | + | ToastPositionTopLeft ToastPosition = iota | |||
| 57 | + | // ToastPositionTopCenter positions toast at top center. | |||
| 58 | + | ToastPositionTopCenter | |||
| 59 | + | // ToastPositionTopRight positions toast in top-right corner. | |||
| 60 | + | ToastPositionTopRight | |||
| 61 | + | // ToastPositionBottomLeft positions toast in bottom-left corner. | |||
| 62 | + | ToastPositionBottomLeft | |||
| 63 | + | // ToastPositionBottomCenter positions toast at bottom center. | |||
| 64 | + | ToastPositionBottomCenter | |||
| 65 | + | // ToastPositionBottomRight positions toast in bottom-right corner. | |||
| 66 | + | ToastPositionBottomRight | |||
| 67 | + | ) | |||
| 68 | + | ||||
| 69 | + | // ToastOptions configures toast appearance and behavior. | |||
| 70 | + | type ToastOptions struct { | |||
| 71 | + | // Duration in milliseconds. 0 = persistent (no auto-dismiss), >0 = auto-dismiss after duration. | |||
| 72 | + | Duration int | |||
| 73 | + | // BackgroundColor overrides the default background color (CSS color value). | |||
| 74 | + | // Empty string uses the level's default color. | |||
| 75 | + | BackgroundColor string | |||
| 76 | + | // TextColor overrides the default text color (CSS color value). | |||
| 77 | + | // Empty string uses the default (auto-contrast with background). | |||
| 78 | + | TextColor string | |||
| 79 | + | // Position determines where the toast appears on screen. | |||
| 80 | + | Position ToastPosition | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // ToastOption is a functional option for configuring toast display. | |||
| 84 | + | type ToastOption func(*ToastOptions) | |||
| 85 | + | ||||
| 86 | + | // WithDuration sets the auto-dismiss duration in milliseconds. | |||
| 87 | + | // Use 0 for persistent toasts that require manual dismissal. | |||
| 88 | + | func WithDuration(ms int) ToastOption { | |||
| 89 | + | return func(o *ToastOptions) { | |||
| 90 | + | o.Duration = ms | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // WithBackgroundColor sets a custom background color (CSS color value). | |||
| 95 | + | func WithBackgroundColor(color string) ToastOption { | |||
| 96 | + | return func(o *ToastOptions) { | |||
| 97 | + | o.BackgroundColor = color | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // WithTextColor sets a custom text color (CSS color value). | |||
| 102 | + | func WithTextColor(color string) ToastOption { | |||
| 103 | + | return func(o *ToastOptions) { | |||
| 104 | + | o.TextColor = color | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // WithPosition sets the toast position on screen. | |||
| 109 | + | func WithPosition(pos ToastPosition) ToastOption { | |||
| 110 | + | return func(o *ToastOptions) { | |||
| 111 | + | o.Position = pos | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // defaultToastOptions returns the default toast options. | |||
| 116 | + | func defaultToastOptions() ToastOptions { | |||
| 117 | + | return ToastOptions{ | |||
| 118 | + | Duration: toastDismissTimeoutMs, | |||
| 119 | + | Position: ToastPositionTopLeft, | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 51 | // Toaster displays toast notifications in an overlay. | 123 | // Toaster displays toast notifications in an overlay. | ||
| 52 | // It supports different notification levels and auto-dismissal. | 124 | // It supports different notification levels and auto-dismissal. | ||
| 53 | // When a new toast is shown while one is already visible, the text | 125 | // When a new toast is shown while one is already visible, the text | ||
| @@ -60,18 +132,24 @@ type Toaster struct { | |||||
| 60 | visible bool | 132 | visible bool | ||
| 61 | dismissTimer uint // GLib timer source ID | 133 | dismissTimer uint // GLib timer source ID | ||
| 62 | 134 | ||||
| 135 | + | // Track current options for cleanup | |||
| 136 | + | currentOpts ToastOptions | |||
| 137 | + | hasCustomStyle bool | |||
| 138 | + | customBgColor string // Current custom background color | |||
| 139 | + | customTextColor string // Current custom text color | |||
| 140 | + | ||||
| 63 | mu sync.Mutex | 141 | mu sync.Mutex | ||
| 64 | } | 142 | } | ||
| 65 | 143 | ||||
| 66 | // NewToaster creates a new toaster component for overlay display. | 144 | // NewToaster creates a new toaster component for overlay display. | ||
| 67 | - | // The toaster is positioned in the top-left corner with margin. | 145 | + | // The toaster is positioned in the top-left corner with margin by default. |
| 68 | func NewToaster(factory layout.WidgetFactory) *Toaster { | 146 | func NewToaster(factory layout.WidgetFactory) *Toaster { | ||
| 69 | // Create container box for styling and positioning | 147 | // Create container box for styling and positioning | ||
| 70 | container := factory.NewBox(layout.OrientationHorizontal, 0) | 148 | container := factory.NewBox(layout.OrientationHorizontal, 0) | ||
| 71 | container.AddCssClass("toast") | 149 | container.AddCssClass("toast") | ||
| 72 | container.AddCssClass("toast-info") // Default level | 150 | container.AddCssClass("toast-info") // Default level | ||
| 73 | 151 | ||||
| 74 | - | // Position in top-left with margin | 152 | + | // Position in top-left with margin (default) |
| 75 | container.SetHalign(gtk.AlignStartValue) | 153 | container.SetHalign(gtk.AlignStartValue) | ||
| 76 | container.SetValign(gtk.AlignStartValue) | 154 | container.SetValign(gtk.AlignStartValue) | ||
| 77 | 155 | ||||
| @@ -98,17 +176,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster { | |||||
| 98 | label: label, | 176 | label: label, | ||
| 99 | currentLevel: ToastInfo, | 177 | currentLevel: ToastInfo, | ||
| 100 | visible: false, | 178 | visible: false, | ||
| 179 | + | currentOpts: defaultToastOptions(), | |||
| 101 | } | 180 | } | ||
| 102 | } | 181 | } | ||
| 103 | 182 | ||||
| 104 | // Show displays a toast notification with the given message and level. | 183 | // Show displays a toast notification with the given message and level. | ||
| 105 | // If a toast is already visible, updates the text and resets the dismiss timer. | 184 | // If a toast is already visible, updates the text and resets the dismiss timer. | ||
| 106 | - | func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | 185 | + | // Optional ToastOption arguments can customize duration, colors, and position. |
| 186 | + | func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) { | |||
| 107 | log := logging.FromContext(ctx) | 187 | log := logging.FromContext(ctx) | ||
| 108 | 188 | ||||
| 189 | + | // Build options from defaults and overrides | |||
| 190 | + | options := defaultToastOptions() | |||
| 191 | + | for _, opt := range opts { | |||
| 192 | + | opt(&options) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 109 | t.mu.Lock() | 195 | t.mu.Lock() | ||
| 110 | defer t.mu.Unlock() | 196 | defer t.mu.Unlock() | ||
| 111 | 197 | ||||
| 198 | + | // Clear previous custom styles if any | |||
| 199 | + | t.clearCustomStyle() | |||
| 200 | + | ||||
| 112 | // Update level CSS class if changed | 201 | // Update level CSS class if changed | ||
| 113 | if t.currentLevel != level { | 202 | if t.currentLevel != level { | ||
| 114 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | 203 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | ||
| @@ -116,6 +205,12 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||||
| 116 | t.currentLevel = level | 205 | t.currentLevel = level | ||
| 117 | } | 206 | } | ||
| 118 | 207 | ||||
| 208 | + | // Apply custom colors if specified | |||
| 209 | + | t.applyCustomStyle(options) | |||
| 210 | + | ||||
| 211 | + | // Apply position | |||
| 212 | + | t.applyPosition(options.Position) | |||
| 213 | + | ||||
| 119 | // Update message text | 214 | // Update message text | ||
| 120 | t.label.SetText(message) | 215 | t.label.SetText(message) | ||
| 121 | 216 | ||||
| @@ -131,12 +226,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||||
| 131 | t.container.SetVisible(true) | 226 | t.container.SetVisible(true) | ||
| 132 | } | 227 | } | ||
| 133 | 228 | ||||
| 134 | - | // Start new dismiss timer | 229 | + | // Start new dismiss timer (if duration > 0) |
| 135 | - | t.startDismissTimer(ctx) | 230 | + | if options.Duration > 0 { |
| 231 | + | t.startDismissTimerWithDuration(ctx, options.Duration) | |||
| 232 | + | } | |||
| 233 | + | // Duration == 0 means persistent, no timer | |||
| 234 | + | ||||
| 235 | + | t.currentOpts = options | |||
| 136 | 236 | ||||
| 137 | log.Debug(). | 237 | log.Debug(). | ||
| 138 | Str("toast_message", message). | 238 | Str("toast_message", message). | ||
| 139 | Int("toast_level", int(level)). | 239 | Int("toast_level", int(level)). | ||
| 240 | + | Int("duration", options.Duration). | |||
| 140 | Msg("toast shown") | 241 | Msg("toast shown") | ||
| 141 | } | 242 | } | ||
| 142 | 243 | ||||
| @@ -167,13 +268,104 @@ func (t *Toaster) hide() { | |||||
| 167 | t.dismissTimer = 0 | 268 | t.dismissTimer = 0 | ||
| 168 | } | 269 | } | ||
| 169 | 270 | ||||
| 271 | + | // Clear custom styles | |||
| 272 | + | t.clearCustomStyle() | |||
| 273 | + | ||||
| 170 | t.visible = false | 274 | t.visible = false | ||
| 171 | t.container.SetVisible(false) | 275 | t.container.SetVisible(false) | ||
| 172 | } | 276 | } | ||
| 173 | 277 | ||||
| 174 | - | // startDismissTimer starts the auto-dismiss timer. | 278 | + | // applyPosition sets the widget alignment based on the position. |
| 279 | + | func (t *Toaster) applyPosition(pos ToastPosition) { | |||
| 280 | + | var halign, valign gtk.Align | |||
| 281 | + | ||||
| 282 | + | switch pos { | |||
| 283 | + | case ToastPositionTopLeft: | |||
| 284 | + | halign = gtk.AlignStartValue | |||
| 285 | + | valign = gtk.AlignStartValue | |||
| 286 | + | case ToastPositionTopCenter: | |||
| 287 | + | halign = gtk.AlignCenterValue | |||
| 288 | + | valign = gtk.AlignStartValue | |||
| 289 | + | case ToastPositionTopRight: | |||
| 290 | + | halign = gtk.AlignEndValue | |||
| 291 | + | valign = gtk.AlignStartValue | |||
| 292 | + | case ToastPositionBottomLeft: | |||
| 293 | + | halign = gtk.AlignStartValue | |||
| 294 | + | valign = gtk.AlignEndValue | |||
| 295 | + | case ToastPositionBottomCenter: | |||
| 296 | + | halign = gtk.AlignCenterValue | |||
| 297 | + | valign = gtk.AlignEndValue | |||
| 298 | + | case ToastPositionBottomRight: | |||
| 299 | + | halign = gtk.AlignEndValue | |||
| 300 | + | valign = gtk.AlignEndValue | |||
| 301 | + | default: | |||
| 302 | + | halign = gtk.AlignStartValue | |||
| 303 | + | valign = gtk.AlignStartValue | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | t.container.SetHalign(halign) | |||
| 307 | + | t.container.SetValign(valign) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // applyCustomStyle applies custom background and text colors. | |||
| 311 | + | // Uses a CSS class that is styled dynamically by the theme manager. | |||
| 312 | + | func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||
| 313 | + | if opts.BackgroundColor == "" && opts.TextColor == "" { | |||
| 314 | + | return | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // Add custom class to indicate custom styling is applied | |||
| 318 | + | t.container.AddCssClass("toast-custom") | |||
| 319 | + | t.hasCustomStyle = true | |||
| 320 | + | ||||
| 321 | + | // Store the colors for access by theme/CSS generation | |||
| 322 | + | t.customBgColor = opts.BackgroundColor | |||
| 323 | + | t.customTextColor = opts.TextColor | |||
| 324 | + | ||||
| 325 | + | // Remove level-based styling when using custom colors | |||
| 326 | + | if opts.BackgroundColor != "" { | |||
| 327 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // clearCustomStyle removes any custom styling applied. | |||
| 332 | + | func (t *Toaster) clearCustomStyle() { | |||
| 333 | + | if t.hasCustomStyle { | |||
| 334 | + | t.container.RemoveCssClass("toast-custom") | |||
| 335 | + | // Remove any mode-specific classes | |||
| 336 | + | t.container.RemoveCssClass("toast-pane-mode") | |||
| 337 | + | t.container.RemoveCssClass("toast-tab-mode") | |||
| 338 | + | t.container.RemoveCssClass("toast-session-mode") | |||
| 339 | + | t.container.RemoveCssClass("toast-resize-mode") | |||
| 340 | + | // Re-add level class if it was removed | |||
| 341 | + | if t.customBgColor != "" { | |||
| 342 | + | t.container.AddCssClass(t.currentLevel.cssClass()) | |||
| 343 | + | } | |||
| 344 | + | t.hasCustomStyle = false | |||
| 345 | + | t.customBgColor = "" | |||
| 346 | + | t.customTextColor = "" | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // ApplyModeClass applies a mode-specific CSS class for styling. | |||
| 351 | + | // This uses the pre-defined CSS classes that reference CSS variables. | |||
| 352 | + | func (t *Toaster) ApplyModeClass(modeClass string) { | |||
| 353 | + | t.mu.Lock() | |||
| 354 | + | defer t.mu.Unlock() | |||
| 355 | + | ||||
| 356 | + | // Clear any existing custom style first | |||
| 357 | + | t.clearCustomStyle() | |||
| 358 | + | ||||
| 359 | + | // Remove level class and add mode class | |||
| 360 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |||
| 361 | + | t.container.AddCssClass(modeClass) | |||
| 362 | + | t.hasCustomStyle = true | |||
| 363 | + | t.customBgColor = modeClass // Store class name for cleanup tracking | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration. | |||
| 175 | // Must be called with lock held. | 367 | // Must be called with lock held. | ||
| 176 | - | func (t *Toaster) startDismissTimer(ctx context.Context) { | 368 | + | func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) { |
| 177 | log := logging.FromContext(ctx) | 369 | log := logging.FromContext(ctx) | ||
| 178 | 370 | ||||
| 179 | cb := glib.SourceFunc(func(_ uintptr) bool { | 371 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| @@ -188,7 +380,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) { | |||||
| 188 | return false // Don't repeat | 380 | return false // Don't repeat | ||
| 189 | }) | 381 | }) | ||
| 190 | 382 | ||||
| 191 | - | t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0) | 383 | + | t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0) |
| 192 | } | 384 | } | ||
| 193 | 385 | ||||
| 194 | // Widget returns the underlying widget for embedding in overlays. | 386 | // Widget returns the underlying widget for embedding in overlays. | ||
M internal/ui/input/modal.go +16 -0
| @@ -42,6 +42,22 @@ func (m Mode) String() string { | |||
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | // DisplayName returns the mode name for display in UI (e.g., toaster). | |
| 46 | + | func (m Mode) DisplayName() string { | |
| 47 | + | switch m { | |
| 48 | + | case ModeTab: | |
| 49 | + | return "TAB MODE" | |
| 50 | + | case ModePane: | |
| 51 | + | return "PANE MODE" | |
| 52 | + | case ModeSession: | |
| 53 | + | return "SESSION MODE" | |
| 54 | + | case ModeResize: | |
| 55 | + | return "RESIZE MODE" | |
| 56 | + | default: | |
| 57 | + | return "" | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 45 | 61 | // ModalState manages the current input mode with optional timeout. | |
| 46 | 62 | type ModalState struct { | |
| 47 | 63 | mode Mode | |
| @@ -42,6 +42,22 @@ func (m Mode) String() string { | |||||
| 42 | } | 42 | } | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | // DisplayName returns the mode name for display in UI (e.g., toaster). | |||
| 46 | + | func (m Mode) DisplayName() string { | |||
| 47 | + | switch m { | |||
| 48 | + | case ModeTab: | |||
| 49 | + | return "TAB MODE" | |||
| 50 | + | case ModePane: | |||
| 51 | + | return "PANE MODE" | |||
| 52 | + | case ModeSession: | |||
| 53 | + | return "SESSION MODE" | |||
| 54 | + | case ModeResize: | |||
| 55 | + | return "RESIZE MODE" | |||
| 56 | + | default: | |||
| 57 | + | return "" | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 45 | // ModalState manages the current input mode with optional timeout. | 61 | // ModalState manages the current input mode with optional timeout. | ||
| 46 | type ModalState struct { | 62 | type ModalState struct { | ||
| 47 | mode Mode | 63 | mode Mode | ||
M internal/ui/theme/css.go +43 -9
| @@ -50,7 +50,14 @@ window, tooltip, popover { | |||
| 50 | 50 | ||
| 51 | 51 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | |
| 52 | 52 | // Scale affects font sizes and widget padding/margins proportionally. | |
| 53 | + | // Uses default mode colors. | |
| 53 | 54 | func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string { | |
| 55 | + | return GenerateCSSFull(p, scale, fonts, DefaultModeColors()) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // GenerateCSSFull creates GTK4 CSS using all provided configuration. | |
| 59 | + | // Scale affects font sizes and widget padding/margins proportionally. | |
| 60 | + | func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string { | |
| 54 | 61 | if scale <= 0 { | |
| 55 | 62 | scale = 1.0 | |
| 56 | 63 | } | |
| @@ -68,6 +75,7 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||
| 68 | 75 | sb.WriteString(":root {\n") | |
| 69 | 76 | sb.WriteString(p.ToCSSVars()) | |
| 70 | 77 | sb.WriteString(FontCSSVars(fonts)) | |
| 78 | + | sb.WriteString(modeColors.ToCSSVars()) | |
| 71 | 79 | sb.WriteString("}\n\n") | |
| 72 | 80 | ||
| 73 | 81 | // Global font styling | |
| @@ -484,26 +492,26 @@ func generatePaneCSS(p Palette) string { | |||
| 484 | 492 | border-color: transparent; | |
| 485 | 493 | } | |
| 486 | 494 | ||
| 487 | - | /* Pane mode active - thick blue inset border (for overlay) */ | |
| 495 | + | /* Pane mode active - thick inset border (for overlay) */ | |
| 488 | 496 | .pane-mode-active { | |
| 489 | 497 | background-color: transparent; | |
| 490 | - | box-shadow: inset 0 0 0 0.25em #4A90E2; | |
| 498 | + | box-shadow: inset 0 0 0 0.25em var(--pane-mode-color); | |
| 491 | 499 | border-radius: 0; | |
| 492 | 500 | } | |
| 493 | 501 | ||
| 494 | - | /* Tab mode active - thick orange inset border (for overlay) */ | |
| 502 | + | /* Tab mode active - thick inset border (for overlay) */ | |
| 495 | 503 | .tab-mode-active { | |
| 496 | 504 | background-color: transparent; | |
| 497 | - | box-shadow: inset 0 0 0 0.25em #FFA500; | |
| 505 | + | box-shadow: inset 0 0 0 0.25em var(--tab-mode-color); | |
| 498 | 506 | border-radius: 0; | |
| 499 | 507 | } | |
| 500 | 508 | ||
| 501 | - | /* Resize mode active - thick teal inset border */ | |
| 509 | + | /* Resize mode active - thick inset border */ | |
| 502 | 510 | .resize-mode-active { | |
| 503 | 511 | background-color: transparent; | |
| 504 | 512 | /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */ | |
| 505 | - | box-shadow: inset 0 0 0 0.25em #00D4AA; | |
| 506 | - | outline: 0.25em solid #00D4AA; | |
| 513 | + | box-shadow: inset 0 0 0 0.25em var(--resize-mode-color); | |
| 514 | + | outline: 0.25em solid var(--resize-mode-color); | |
| 507 | 515 | outline-offset: -0.25em; | |
| 508 | 516 | border-radius: 0; | |
| 509 | 517 | } | |
| @@ -751,10 +759,10 @@ row:selected .session-manager-row { | |||
| 751 | 759 | font-family: var(--font-mono); | |
| 752 | 760 | } | |
| 753 | 761 | ||
| 754 | - | /* Session mode border (purple) */ | |
| 762 | + | /* Session mode border */ | |
| 755 | 763 | .session-mode-active { | |
| 756 | 764 | background-color: transparent; | |
| 757 | - | box-shadow: inset 0 0 0 0.25em #9B59B6; | |
| 765 | + | box-shadow: inset 0 0 0 0.25em var(--session-mode-color); | |
| 758 | 766 | border-radius: 0; | |
| 759 | 767 | } | |
| 760 | 768 | ` | |
| @@ -800,6 +808,32 @@ func generateToasterCSS(p Palette) string { | |||
| 800 | 808 | background-color: alpha(var(--destructive), 0.9); | |
| 801 | 809 | color: var(--bg); | |
| 802 | 810 | } | |
| 811 | + | ||
| 812 | + | /* Toast with custom styling (mode indicator toasters) */ | |
| 813 | + | .toast-custom { | |
| 814 | + | color: #ffffff; | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | /* Mode-specific toast colors */ | |
| 818 | + | .toast-pane-mode { | |
| 819 | + | background-color: var(--pane-mode-color); | |
| 820 | + | color: #ffffff; | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | .toast-tab-mode { | |
| 824 | + | background-color: var(--tab-mode-color); | |
| 825 | + | color: #ffffff; | |
| 826 | + | } | |
| 827 | + | ||
| 828 | + | .toast-session-mode { | |
| 829 | + | background-color: var(--session-mode-color); | |
| 830 | + | color: #ffffff; | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | .toast-resize-mode { | |
| 834 | + | background-color: var(--resize-mode-color); | |
| 835 | + | color: #ffffff; | |
| 836 | + | } | |
| 803 | 837 | ` | |
| 804 | 838 | } | |
| 805 | 839 | ||
| @@ -50,7 +50,14 @@ window, tooltip, popover { | |||||
| 50 | 50 | ||||
| 51 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | 51 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | ||
| 52 | // Scale affects font sizes and widget padding/margins proportionally. | 52 | // Scale affects font sizes and widget padding/margins proportionally. | ||
| 53 | + | // Uses default mode colors. | |||
| 53 | func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string { | 54 | func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string { | ||
| 55 | + | return GenerateCSSFull(p, scale, fonts, DefaultModeColors()) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // GenerateCSSFull creates GTK4 CSS using all provided configuration. | |||
| 59 | + | // Scale affects font sizes and widget padding/margins proportionally. | |||
| 60 | + | func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string { | |||
| 54 | if scale <= 0 { | 61 | if scale <= 0 { | ||
| 55 | scale = 1.0 | 62 | scale = 1.0 | ||
| 56 | } | 63 | } | ||
| @@ -68,6 +75,7 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||||
| 68 | sb.WriteString(":root {\n") | 75 | sb.WriteString(":root {\n") | ||
| 69 | sb.WriteString(p.ToCSSVars()) | 76 | sb.WriteString(p.ToCSSVars()) | ||
| 70 | sb.WriteString(FontCSSVars(fonts)) | 77 | sb.WriteString(FontCSSVars(fonts)) | ||
| 78 | + | sb.WriteString(modeColors.ToCSSVars()) | |||
| 71 | sb.WriteString("}\n\n") | 79 | sb.WriteString("}\n\n") | ||
| 72 | 80 | ||||
| 73 | // Global font styling | 81 | // Global font styling | ||
| @@ -484,26 +492,26 @@ func generatePaneCSS(p Palette) string { | |||||
| 484 | border-color: transparent; | 492 | border-color: transparent; | ||
| 485 | } | 493 | } | ||
| 486 | 494 | ||||
| 487 | - | /* Pane mode active - thick blue inset border (for overlay) */ | 495 | + | /* Pane mode active - thick inset border (for overlay) */ |
| 488 | .pane-mode-active { | 496 | .pane-mode-active { | ||
| 489 | background-color: transparent; | 497 | background-color: transparent; | ||
| 490 | - | box-shadow: inset 0 0 0 0.25em #4A90E2; | 498 | + | box-shadow: inset 0 0 0 0.25em var(--pane-mode-color); |
| 491 | border-radius: 0; | 499 | border-radius: 0; | ||
| 492 | } | 500 | } | ||
| 493 | 501 | ||||
| 494 | - | /* Tab mode active - thick orange inset border (for overlay) */ | 502 | + | /* Tab mode active - thick inset border (for overlay) */ |
| 495 | .tab-mode-active { | 503 | .tab-mode-active { | ||
| 496 | background-color: transparent; | 504 | background-color: transparent; | ||
| 497 | - | box-shadow: inset 0 0 0 0.25em #FFA500; | 505 | + | box-shadow: inset 0 0 0 0.25em var(--tab-mode-color); |
| 498 | border-radius: 0; | 506 | border-radius: 0; | ||
| 499 | } | 507 | } | ||
| 500 | 508 | ||||
| 501 | - | /* Resize mode active - thick teal inset border */ | 509 | + | /* Resize mode active - thick inset border */ |
| 502 | .resize-mode-active { | 510 | .resize-mode-active { | ||
| 503 | background-color: transparent; | 511 | background-color: transparent; | ||
| 504 | /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */ | 512 | /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */ | ||
| 505 | - | box-shadow: inset 0 0 0 0.25em #00D4AA; | 513 | + | box-shadow: inset 0 0 0 0.25em var(--resize-mode-color); |
| 506 | - | outline: 0.25em solid #00D4AA; | 514 | + | outline: 0.25em solid var(--resize-mode-color); |
| 507 | outline-offset: -0.25em; | 515 | outline-offset: -0.25em; | ||
| 508 | border-radius: 0; | 516 | border-radius: 0; | ||
| 509 | } | 517 | } | ||
| @@ -751,10 +759,10 @@ row:selected .session-manager-row { | |||||
| 751 | font-family: var(--font-mono); | 759 | font-family: var(--font-mono); | ||
| 752 | } | 760 | } | ||
| 753 | 761 | ||||
| 754 | - | /* Session mode border (purple) */ | 762 | + | /* Session mode border */ |
| 755 | .session-mode-active { | 763 | .session-mode-active { | ||
| 756 | background-color: transparent; | 764 | background-color: transparent; | ||
| 757 | - | box-shadow: inset 0 0 0 0.25em #9B59B6; | 765 | + | box-shadow: inset 0 0 0 0.25em var(--session-mode-color); |
| 758 | border-radius: 0; | 766 | border-radius: 0; | ||
| 759 | } | 767 | } | ||
| 760 | ` | 768 | ` | ||
| @@ -800,6 +808,32 @@ func generateToasterCSS(p Palette) string { | |||||
| 800 | background-color: alpha(var(--destructive), 0.9); | 808 | background-color: alpha(var(--destructive), 0.9); | ||
| 801 | color: var(--bg); | 809 | color: var(--bg); | ||
| 802 | } | 810 | } | ||
| 811 | + | ||||
| 812 | + | /* Toast with custom styling (mode indicator toasters) */ | |||
| 813 | + | .toast-custom { | |||
| 814 | + | color: #ffffff; | |||
| 815 | + | } | |||
| 816 | + | ||||
| 817 | + | /* Mode-specific toast colors */ | |||
| 818 | + | .toast-pane-mode { | |||
| 819 | + | background-color: var(--pane-mode-color); | |||
| 820 | + | color: #ffffff; | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | .toast-tab-mode { | |||
| 824 | + | background-color: var(--tab-mode-color); | |||
| 825 | + | color: #ffffff; | |||
| 826 | + | } | |||
| 827 | + | ||||
| 828 | + | .toast-session-mode { | |||
| 829 | + | background-color: var(--session-mode-color); | |||
| 830 | + | color: #ffffff; | |||
| 831 | + | } | |||
| 832 | + | ||||
| 833 | + | .toast-resize-mode { | |||
| 834 | + | background-color: var(--resize-mode-color); | |||
| 835 | + | color: #ffffff; | |||
| 836 | + | } | |||
| 803 | ` | 837 | ` | ||
| 804 | } | 838 | } | ||
| 805 | 839 | ||||
M internal/ui/theme/manager.go +30 -2
| @@ -18,6 +18,7 @@ type Manager struct { | |||
| 18 | 18 | darkPalette Palette // Dark theme colors | |
| 19 | 19 | uiScale float64 // UI scaling factor (1.0 = 100%) | |
| 20 | 20 | fonts FontConfig | |
| 21 | + | modeColors ModeColors // Modal mode indicator colors | |
| 21 | 22 | cssProvider *gtk.CssProvider | |
| 22 | 23 | } | |
| 23 | 24 | ||
| @@ -56,6 +57,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||
| 56 | 57 | fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont) | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 60 | + | // Build mode colors from config | |
| 61 | + | modeColors := modeColorsFromConfig(cfg) | |
| 62 | + | ||
| 59 | 63 | log.Debug(). | |
| 60 | 64 | Str("scheme", scheme). | |
| 61 | 65 | Bool("prefers_dark", prefersDark). | |
| @@ -71,6 +75,22 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||
| 71 | 75 | darkPalette: darkPalette, | |
| 72 | 76 | uiScale: uiScale, | |
| 73 | 77 | fonts: fonts, | |
| 78 | + | modeColors: modeColors, | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // modeColorsFromConfig extracts mode colors from config, using defaults for missing values. | |
| 83 | + | func modeColorsFromConfig(cfg *config.Config) ModeColors { | |
| 84 | + | defaults := DefaultModeColors() | |
| 85 | + | if cfg == nil { | |
| 86 | + | return defaults | |
| 87 | + | } | |
| 88 | + | styling := &cfg.Workspace.Styling | |
| 89 | + | return ModeColors{ | |
| 90 | + | PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode), | |
| 91 | + | TabMode: Coalesce(styling.TabModeColor, defaults.TabMode), | |
| 92 | + | SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode), | |
| 93 | + | ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode), | |
| 74 | 94 | } | |
| 75 | 95 | } | |
| 76 | 96 | ||
| @@ -104,6 +124,11 @@ func (m *Manager) GetDarkPalette() Palette { | |||
| 104 | 124 | return m.darkPalette | |
| 105 | 125 | } | |
| 106 | 126 | ||
| 127 | + | // GetModeColors returns the modal mode indicator colors. | |
| 128 | + | func (m *Manager) GetModeColors() ModeColors { | |
| 129 | + | return m.modeColors | |
| 130 | + | } | |
| 131 | + | ||
| 107 | 132 | // GetWebUIThemeCSS returns CSS text that defines both light and dark variables. | |
| 108 | 133 | // WebUI uses `:root` for light and `.dark` overrides for dark. | |
| 109 | 134 | func (m *Manager) GetWebUIThemeCSS() string { | |
| @@ -121,9 +146,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||
| 121 | 146 | return | |
| 122 | 147 | } | |
| 123 | 148 | ||
| 124 | - | // Generate CSS with current palette, UI scale and fonts | |
| 149 | + | // Generate CSS with current palette, UI scale, fonts, and mode colors | |
| 125 | 150 | palette := m.GetCurrentPalette() | |
| 126 | - | css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts) | |
| 151 | + | css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors) | |
| 127 | 152 | ||
| 128 | 153 | // Create CSS provider if needed | |
| 129 | 154 | if m.cssProvider == nil { | |
| @@ -197,6 +222,9 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp | |||
| 197 | 222 | MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont), | |
| 198 | 223 | } | |
| 199 | 224 | ||
| 225 | + | // Update mode colors | |
| 226 | + | m.modeColors = modeColorsFromConfig(cfg) | |
| 227 | + | ||
| 200 | 228 | log.Info(). | |
| 201 | 229 | Str("scheme", m.scheme). | |
| 202 | 230 | Bool("prefers_dark", m.prefersDark). | |
| @@ -18,6 +18,7 @@ type Manager struct { | |||||
| 18 | darkPalette Palette // Dark theme colors | 18 | darkPalette Palette // Dark theme colors | ||
| 19 | uiScale float64 // UI scaling factor (1.0 = 100%) | 19 | uiScale float64 // UI scaling factor (1.0 = 100%) | ||
| 20 | fonts FontConfig | 20 | fonts FontConfig | ||
| 21 | + | modeColors ModeColors // Modal mode indicator colors | |||
| 21 | cssProvider *gtk.CssProvider | 22 | cssProvider *gtk.CssProvider | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| @@ -56,6 +57,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||||
| 56 | fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont) | 57 | fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont) | ||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 60 | + | // Build mode colors from config | |||
| 61 | + | modeColors := modeColorsFromConfig(cfg) | |||
| 62 | + | ||||
| 59 | log.Debug(). | 63 | log.Debug(). | ||
| 60 | Str("scheme", scheme). | 64 | Str("scheme", scheme). | ||
| 61 | Bool("prefers_dark", prefersDark). | 65 | Bool("prefers_dark", prefersDark). | ||
| @@ -71,6 +75,22 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||||
| 71 | darkPalette: darkPalette, | 75 | darkPalette: darkPalette, | ||
| 72 | uiScale: uiScale, | 76 | uiScale: uiScale, | ||
| 73 | fonts: fonts, | 77 | fonts: fonts, | ||
| 78 | + | modeColors: modeColors, | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // modeColorsFromConfig extracts mode colors from config, using defaults for missing values. | |||
| 83 | + | func modeColorsFromConfig(cfg *config.Config) ModeColors { | |||
| 84 | + | defaults := DefaultModeColors() | |||
| 85 | + | if cfg == nil { | |||
| 86 | + | return defaults | |||
| 87 | + | } | |||
| 88 | + | styling := &cfg.Workspace.Styling | |||
| 89 | + | return ModeColors{ | |||
| 90 | + | PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode), | |||
| 91 | + | TabMode: Coalesce(styling.TabModeColor, defaults.TabMode), | |||
| 92 | + | SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode), | |||
| 93 | + | ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode), | |||
| 74 | } | 94 | } | ||
| 75 | } | 95 | } | ||
| 76 | 96 | ||||
| @@ -104,6 +124,11 @@ func (m *Manager) GetDarkPalette() Palette { | |||||
| 104 | return m.darkPalette | 124 | return m.darkPalette | ||
| 105 | } | 125 | } | ||
| 106 | 126 | ||||
| 127 | + | // GetModeColors returns the modal mode indicator colors. | |||
| 128 | + | func (m *Manager) GetModeColors() ModeColors { | |||
| 129 | + | return m.modeColors | |||
| 130 | + | } | |||
| 131 | + | ||||
| 107 | // GetWebUIThemeCSS returns CSS text that defines both light and dark variables. | 132 | // GetWebUIThemeCSS returns CSS text that defines both light and dark variables. | ||
| 108 | // WebUI uses `:root` for light and `.dark` overrides for dark. | 133 | // WebUI uses `:root` for light and `.dark` overrides for dark. | ||
| 109 | func (m *Manager) GetWebUIThemeCSS() string { | 134 | func (m *Manager) GetWebUIThemeCSS() string { | ||
| @@ -121,9 +146,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||||
| 121 | return | 146 | return | ||
| 122 | } | 147 | } | ||
| 123 | 148 | ||||
| 124 | - | // Generate CSS with current palette, UI scale and fonts | 149 | + | // Generate CSS with current palette, UI scale, fonts, and mode colors |
| 125 | palette := m.GetCurrentPalette() | 150 | palette := m.GetCurrentPalette() | ||
| 126 | - | css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts) | 151 | + | css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors) |
| 127 | 152 | ||||
| 128 | // Create CSS provider if needed | 153 | // Create CSS provider if needed | ||
| 129 | if m.cssProvider == nil { | 154 | if m.cssProvider == nil { | ||
| @@ -197,6 +222,9 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp | |||||
| 197 | MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont), | 222 | MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont), | ||
| 198 | } | 223 | } | ||
| 199 | 224 | ||||
| 225 | + | // Update mode colors | |||
| 226 | + | m.modeColors = modeColorsFromConfig(cfg) | |||
| 227 | + | ||||
| 200 | log.Info(). | 228 | log.Info(). | ||
| 201 | Str("scheme", m.scheme). | 229 | Str("scheme", m.scheme). | ||
| 202 | Bool("prefers_dark", m.prefersDark). | 230 | Bool("prefers_dark", m.prefersDark). | ||
M internal/ui/theme/palette.go +28 -0
| @@ -24,6 +24,34 @@ type Palette struct { | |||
| 24 | 24 | Destructive string // Error/destructive actions | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | + | // ModeColors holds colors for modal mode indicators (borders and toasters). | |
| 28 | + | type ModeColors struct { | |
| 29 | + | PaneMode string // Color for pane mode (default: #4A90E2 blue) | |
| 30 | + | TabMode string // Color for tab mode (default: #FFA500 orange) | |
| 31 | + | SessionMode string // Color for session mode (default: #9B59B6 purple) | |
| 32 | + | ResizeMode string // Color for resize mode (default: #00D4AA teal) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // DefaultModeColors returns the default mode indicator colors. | |
| 36 | + | func DefaultModeColors() ModeColors { | |
| 37 | + | return ModeColors{ | |
| 38 | + | PaneMode: "#4A90E2", | |
| 39 | + | TabMode: "#FFA500", | |
| 40 | + | SessionMode: "#9B59B6", | |
| 41 | + | ResizeMode: "#00D4AA", | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ToCSSVars generates CSS custom property declarations for mode colors. | |
| 46 | + | func (m ModeColors) ToCSSVars() string { | |
| 47 | + | var sb strings.Builder | |
| 48 | + | sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n") | |
| 49 | + | sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n") | |
| 50 | + | sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n") | |
| 51 | + | sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n") | |
| 52 | + | return sb.String() | |
| 53 | + | } | |
| 54 | + | ||
| 27 | 55 | // DefaultDarkPalette returns the default dark theme palette. | |
| 28 | 56 | func DefaultDarkPalette() Palette { | |
| 29 | 57 | return Palette{ | |
| @@ -24,6 +24,34 @@ type Palette struct { | |||||
| 24 | Destructive string // Error/destructive actions | 24 | Destructive string // Error/destructive actions | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | + | // ModeColors holds colors for modal mode indicators (borders and toasters). | |||
| 28 | + | type ModeColors struct { | |||
| 29 | + | PaneMode string // Color for pane mode (default: #4A90E2 blue) | |||
| 30 | + | TabMode string // Color for tab mode (default: #FFA500 orange) | |||
| 31 | + | SessionMode string // Color for session mode (default: #9B59B6 purple) | |||
| 32 | + | ResizeMode string // Color for resize mode (default: #00D4AA teal) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // DefaultModeColors returns the default mode indicator colors. | |||
| 36 | + | func DefaultModeColors() ModeColors { | |||
| 37 | + | return ModeColors{ | |||
| 38 | + | PaneMode: "#4A90E2", | |||
| 39 | + | TabMode: "#FFA500", | |||
| 40 | + | SessionMode: "#9B59B6", | |||
| 41 | + | ResizeMode: "#00D4AA", | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // ToCSSVars generates CSS custom property declarations for mode colors. | |||
| 46 | + | func (m ModeColors) ToCSSVars() string { | |||
| 47 | + | var sb strings.Builder | |||
| 48 | + | sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n") | |||
| 49 | + | sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n") | |||
| 50 | + | sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n") | |||
| 51 | + | sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n") | |||
| 52 | + | return sb.String() | |||
| 53 | + | } | |||
| 54 | + | ||||
| 27 | // DefaultDarkPalette returns the default dark theme palette. | 55 | // DefaultDarkPalette returns the default dark theme palette. | ||
| 28 | func DefaultDarkPalette() Palette { | 56 | func DefaultDarkPalette() Palette { | ||
| 29 | return Palette{ | 57 | return Palette{ | ||