Merge pull request #49 from bnema/feature/mode-indicator-toaster
feat: add mode indicator toaster with config migration improvements
23 files · +1681 -141
M .mockery.yaml +1 -0
| @@ -10,6 +10,7 @@ packages: | |||
| 10 | 10 | interfaces: | |
| 11 | 11 | ConfigMigrator: {} | |
| 12 | 12 | DatabaseProvider: {} | |
| 13 | + | DiffFormatter: {} | |
| 13 | 14 | SessionLogger: {} | |
| 14 | 15 | SessionSpawner: {} | |
| 15 | 16 | TabListProvider: {} | |
| @@ -10,6 +10,7 @@ packages: | |||||
| 10 | interfaces: | 10 | interfaces: | ||
| 11 | ConfigMigrator: {} | 11 | ConfigMigrator: {} | ||
| 12 | DatabaseProvider: {} | 12 | DatabaseProvider: {} | ||
| 13 | + | DiffFormatter: {} | |||
| 13 | SessionLogger: {} | 14 | SessionLogger: {} | ||
| 14 | SessionSpawner: {} | 15 | SessionSpawner: {} | ||
| 15 | TabListProvider: {} | 16 | TabListProvider: {} | ||
M CHANGELOG.md +2 -0
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | + | - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true). | |
| 8 | 9 | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | |
| 9 | 10 | - `dumber config status`: Shows config file path and number of new settings available. | |
| 10 | 11 | - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file. | |
| @@ -35,6 +36,7 @@ All notable changes to this project will be documented in this file. | |||
| 35 | 36 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | |
| 36 | 37 | ||
| 37 | 38 | ### Fixed | |
| 39 | + | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | |
| 38 | 40 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | |
| 39 | 41 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | |
| 40 | 42 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | |
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | + | - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true). | |||
| 8 | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | 9 | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | ||
| 9 | - `dumber config status`: Shows config file path and number of new settings available. | 10 | - `dumber config status`: Shows config file path and number of new settings available. | ||
| 10 | - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file. | 11 | - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file. | ||
| @@ -35,6 +36,7 @@ All notable changes to this project will be documented in this file. | |||||
| 35 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | 36 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | ||
| 36 | 37 | ||||
| 37 | ### Fixed | 38 | ### Fixed | ||
| 39 | + | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | |||
| 38 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | 40 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | ||
| 39 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | 41 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | ||
| 40 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | 42 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | ||
M README.md +2 -2
| @@ -307,7 +307,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl | |||
| 307 | 307 | 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode | |
| 308 | 308 | 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout | |
| 309 | 309 | ||
| 310 | - | Tab mode provides visual feedback with an orange border indicator. | |
| 310 | + | ||
| 311 | 311 | ||
| 312 | 312 | ## Pane Management | |
| 313 | 313 | ||
| @@ -335,7 +335,7 @@ Dumber automatically saves your browser session (tabs, panes, split ratios, URLs | |||
| 335 | 335 | 4. **Restore**: Select an exited session to restore it in a new browser window | |
| 336 | 336 | 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting | |
| 337 | 337 | ||
| 338 | - | Session mode provides visual feedback with a purple border indicator. | |
| 338 | + | ||
| 339 | 339 | ||
| 340 | 340 | ## Configuration | |
| 341 | 341 | ||
| @@ -307,7 +307,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl | |||||
| 307 | 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode | 307 | 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode | ||
| 308 | 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout | 308 | 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout | ||
| 309 | 309 | ||||
| 310 | - | Tab mode provides visual feedback with an orange border indicator. | 310 | + | |
| 311 | 311 | ||||
| 312 | ## Pane Management | 312 | ## Pane Management | ||
| 313 | 313 | ||||
| @@ -335,7 +335,7 @@ Dumber automatically saves your browser session (tabs, panes, split ratios, URLs | |||||
| 335 | 4. **Restore**: Select an exited session to restore it in a new browser window | 335 | 4. **Restore**: Select an exited session to restore it in a new browser window | ||
| 336 | 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting | 336 | 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting | ||
| 337 | 337 | ||||
| 338 | - | Session mode provides visual feedback with a purple border indicator. | 338 | + | |
| 339 | 339 | ||||
| 340 | ## Configuration | 340 | ## Configuration | ||
| 341 | 341 | ||||
M docs/CONFIG.md +20 -8
| @@ -333,16 +333,28 @@ Notes: | |||
| 333 | 333 | |-----|------|---------|-------------| | |
| 334 | 334 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | |
| 335 | 335 | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | |
| 336 | - | | `workspace.styling.pane_mode_border_width` | int | `4` | Pane mode border width (px) - Ctrl+P N overlay | | |
| 337 | - | | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) | | |
| 338 | - | | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay | | |
| 339 | - | | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) | | |
| 340 | - | | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay | | |
| 341 | - | | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) | | |
| 342 | - | | `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) | | |
| 343 | - | | `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) | | |
| 336 | + | | `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes | | |
| 337 | + | | `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster | | |
| 338 | + | | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster | | |
| 339 | + | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | |
| 340 | + | | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster | | |
| 341 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active | | |
| 344 | 342 | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | |
| 345 | 343 | ||
| 344 | + | **Example:** | |
| 345 | + | ```toml | |
| 346 | + | [workspace.styling] | |
| 347 | + | border_width = 1 | |
| 348 | + | border_color = "@theme_selected_bg_color" | |
| 349 | + | mode_border_width = 4 | |
| 350 | + | pane_mode_color = "#4A90E2" # Blue for pane mode | |
| 351 | + | tab_mode_color = "#FFA500" # Orange for tab mode | |
| 352 | + | session_mode_color = "#9B59B6" # Purple for session mode | |
| 353 | + | resize_mode_color = "#00D4AA" # Teal for resize mode | |
| 354 | + | mode_indicator_toaster_enabled = true | |
| 355 | + | transition_duration = 120 | |
| 356 | + | ``` | |
| 357 | + | ||
| 346 | 358 | ## Session | |
| 347 | 359 | ||
| 348 | 360 | | Key | Type | Default | Description | | |
| @@ -333,16 +333,28 @@ Notes: | |||||
| 333 | |-----|------|---------|-------------| | 333 | |-----|------|---------|-------------| | ||
| 334 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | 334 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | ||
| 335 | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | 335 | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | ||
| 336 | - | | `workspace.styling.pane_mode_border_width` | int | `4` | Pane mode border width (px) - Ctrl+P N overlay | | 336 | + | | `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes | |
| 337 | - | | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) | | 337 | + | | `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster | |
| 338 | - | | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay | | 338 | + | | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster | |
| 339 | - | | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) | | 339 | + | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | |
| 340 | - | | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay | | 340 | + | | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster | |
| 341 | - | | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) | | 341 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active | |
| 342 | - | | `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) | | |||
| 343 | - | | `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) | | |||
| 344 | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | 342 | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | ||
| 345 | 343 | ||||
| 344 | + | **Example:** | |||
| 345 | + | ```toml | |||
| 346 | + | [workspace.styling] | |||
| 347 | + | border_width = 1 | |||
| 348 | + | border_color = "@theme_selected_bg_color" | |||
| 349 | + | mode_border_width = 4 | |||
| 350 | + | pane_mode_color = "#4A90E2" # Blue for pane mode | |||
| 351 | + | tab_mode_color = "#FFA500" # Orange for tab mode | |||
| 352 | + | session_mode_color = "#9B59B6" # Purple for session mode | |||
| 353 | + | resize_mode_color = "#00D4AA" # Teal for resize mode | |||
| 354 | + | mode_indicator_toaster_enabled = true | |||
| 355 | + | transition_duration = 120 | |||
| 356 | + | ``` | |||
| 357 | + | ||||
| 346 | ## Session | 358 | ## Session | ||
| 347 | 359 | ||||
| 348 | | Key | Type | Default | Description | | 360 | | Key | Type | Default | Description | | ||
M internal/application/port/config_migrator.go +46 -1
| @@ -18,16 +18,61 @@ 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 | + | ) | |
| 32 | + | ||
| 33 | + | // String returns a display symbol for the change type. | |
| 34 | + | func (t KeyChangeType) String() string { | |
| 35 | + | switch t { | |
| 36 | + | case KeyChangeAdded: | |
| 37 | + | return "+" | |
| 38 | + | case KeyChangeRemoved: | |
| 39 | + | return "-" | |
| 40 | + | case KeyChangeRenamed: | |
| 41 | + | return "~" | |
| 42 | + | default: | |
| 43 | + | return "?" | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // KeyChange represents a detected change between user config and defaults. | |
| 48 | + | type KeyChange struct { | |
| 49 | + | Type KeyChangeType // Type of change | |
| 50 | + | OldKey string // Old key name (for removed/renamed) | |
| 51 | + | NewKey string // New key name (for added/renamed) | |
| 52 | + | OldValue string // Old value (for renamed/consolidated) | |
| 53 | + | NewValue string // New/default value | |
| 54 | + | } | |
| 55 | + | ||
| 21 | 56 | // ConfigMigrator checks for and applies config migrations. | |
| 22 | 57 | type ConfigMigrator interface { | |
| 23 | 58 | // CheckMigration checks if user config is missing any default keys. | |
| 24 | 59 | // Returns nil if no migration is needed (config file doesn't exist or is complete). | |
| 25 | 60 | CheckMigration() (*MigrationResult, error) | |
| 26 | 61 | ||
| 62 | + | // DetectChanges analyzes user config and returns all detected changes. | |
| 63 | + | // This provides a detailed diff-like view of what migration would do. | |
| 64 | + | DetectChanges() ([]KeyChange, error) | |
| 65 | + | ||
| 27 | 66 | // Migrate adds missing default keys to the user's config file. | |
| 28 | - | // Returns the list of keys that were added. | |
| 67 | + | // Returns the list of keys that were added/renamed. | |
| 29 | 68 | Migrate() ([]string, error) | |
| 30 | 69 | ||
| 31 | 70 | // GetKeyInfo returns detailed information about a config key. | |
| 32 | 71 | GetKeyInfo(key string) KeyInfo | |
| 33 | 72 | } | |
| 73 | + | ||
| 74 | + | // DiffFormatter formats config changes for display. | |
| 75 | + | type DiffFormatter interface { | |
| 76 | + | // FormatChangesAsDiff returns changes formatted as a diff-like string for display. | |
| 77 | + | FormatChangesAsDiff(changes []KeyChange) string | |
| 78 | + | } | |
| @@ -18,16 +18,61 @@ 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 | + | ) | |||
| 32 | + | ||||
| 33 | + | // String returns a display symbol for the change type. | |||
| 34 | + | func (t KeyChangeType) String() string { | |||
| 35 | + | switch t { | |||
| 36 | + | case KeyChangeAdded: | |||
| 37 | + | return "+" | |||
| 38 | + | case KeyChangeRemoved: | |||
| 39 | + | return "-" | |||
| 40 | + | case KeyChangeRenamed: | |||
| 41 | + | return "~" | |||
| 42 | + | default: | |||
| 43 | + | return "?" | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // KeyChange represents a detected change between user config and defaults. | |||
| 48 | + | type KeyChange struct { | |||
| 49 | + | Type KeyChangeType // Type of change | |||
| 50 | + | OldKey string // Old key name (for removed/renamed) | |||
| 51 | + | NewKey string // New key name (for added/renamed) | |||
| 52 | + | OldValue string // Old value (for renamed/consolidated) | |||
| 53 | + | NewValue string // New/default value | |||
| 54 | + | } | |||
| 55 | + | ||||
| 21 | // ConfigMigrator checks for and applies config migrations. | 56 | // ConfigMigrator checks for and applies config migrations. | ||
| 22 | type ConfigMigrator interface { | 57 | type ConfigMigrator interface { | ||
| 23 | // CheckMigration checks if user config is missing any default keys. | 58 | // 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). | 59 | // Returns nil if no migration is needed (config file doesn't exist or is complete). | ||
| 25 | CheckMigration() (*MigrationResult, error) | 60 | CheckMigration() (*MigrationResult, error) | ||
| 26 | 61 | ||||
| 62 | + | // DetectChanges analyzes user config and returns all detected changes. | |||
| 63 | + | // This provides a detailed diff-like view of what migration would do. | |||
| 64 | + | DetectChanges() ([]KeyChange, error) | |||
| 65 | + | ||||
| 27 | // Migrate adds missing default keys to the user's config file. | 66 | // Migrate adds missing default keys to the user's config file. | ||
| 28 | - | // Returns the list of keys that were added. | 67 | + | // Returns the list of keys that were added/renamed. |
| 29 | Migrate() ([]string, error) | 68 | Migrate() ([]string, error) | ||
| 30 | 69 | ||||
| 31 | // GetKeyInfo returns detailed information about a config key. | 70 | // GetKeyInfo returns detailed information about a config key. | ||
| 32 | GetKeyInfo(key string) KeyInfo | 71 | GetKeyInfo(key string) KeyInfo | ||
| 33 | } | 72 | } | ||
| 73 | + | ||||
| 74 | + | // DiffFormatter formats config changes for display. | |||
| 75 | + | type DiffFormatter interface { | |||
| 76 | + | // FormatChangesAsDiff returns changes formatted as a diff-like string for display. | |||
| 77 | + | FormatChangesAsDiff(changes []KeyChange) string | |||
| 78 | + | } | |||
M internal/application/port/mocks/mocks.go +133 -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) | |
| @@ -202,6 +257,84 @@ func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, er | |||
| 202 | 257 | return _c | |
| 203 | 258 | } | |
| 204 | 259 | ||
| 260 | + | // NewMockDiffFormatter creates a new instance of MockDiffFormatter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 261 | + | // The first argument is typically a *testing.T value. | |
| 262 | + | func NewMockDiffFormatter(t interface { | |
| 263 | + | mock.TestingT | |
| 264 | + | Cleanup(func()) | |
| 265 | + | }) *MockDiffFormatter { | |
| 266 | + | mock := &MockDiffFormatter{} | |
| 267 | + | mock.Mock.Test(t) | |
| 268 | + | ||
| 269 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 270 | + | ||
| 271 | + | return mock | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // MockDiffFormatter is an autogenerated mock type for the DiffFormatter type | |
| 275 | + | type MockDiffFormatter struct { | |
| 276 | + | mock.Mock | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | type MockDiffFormatter_Expecter struct { | |
| 280 | + | mock *mock.Mock | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (_m *MockDiffFormatter) EXPECT() *MockDiffFormatter_Expecter { | |
| 284 | + | return &MockDiffFormatter_Expecter{mock: &_m.Mock} | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // FormatChangesAsDiff provides a mock function for the type MockDiffFormatter | |
| 288 | + | func (_mock *MockDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |
| 289 | + | ret := _mock.Called(changes) | |
| 290 | + | ||
| 291 | + | if len(ret) == 0 { | |
| 292 | + | panic("no return value specified for FormatChangesAsDiff") | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | var r0 string | |
| 296 | + | if returnFunc, ok := ret.Get(0).(func([]port.KeyChange) string); ok { | |
| 297 | + | r0 = returnFunc(changes) | |
| 298 | + | } else { | |
| 299 | + | r0 = ret.Get(0).(string) | |
| 300 | + | } | |
| 301 | + | return r0 | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // MockDiffFormatter_FormatChangesAsDiff_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FormatChangesAsDiff' | |
| 305 | + | type MockDiffFormatter_FormatChangesAsDiff_Call struct { | |
| 306 | + | *mock.Call | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // FormatChangesAsDiff is a helper method to define mock.On call | |
| 310 | + | // - changes []port.KeyChange | |
| 311 | + | func (_e *MockDiffFormatter_Expecter) FormatChangesAsDiff(changes interface{}) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 312 | + | return &MockDiffFormatter_FormatChangesAsDiff_Call{Call: _e.mock.On("FormatChangesAsDiff", changes)} | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Run(run func(changes []port.KeyChange)) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 316 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 317 | + | var arg0 []port.KeyChange | |
| 318 | + | if args[0] != nil { | |
| 319 | + | arg0 = args[0].([]port.KeyChange) | |
| 320 | + | } | |
| 321 | + | run( | |
| 322 | + | arg0, | |
| 323 | + | ) | |
| 324 | + | }) | |
| 325 | + | return _c | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Return(s string) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 329 | + | _c.Call.Return(s) | |
| 330 | + | return _c | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(changes []port.KeyChange) string) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 334 | + | _c.Call.Return(run) | |
| 335 | + | return _c | |
| 336 | + | } | |
| 337 | + | ||
| 205 | 338 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 206 | 339 | // The first argument is typically a *testing.T value. | |
| 207 | 340 | func NewMockDatabaseProvider(t interface { | |
| @@ -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) | ||
| @@ -202,6 +257,84 @@ func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, er | |||||
| 202 | return _c | 257 | return _c | ||
| 203 | } | 258 | } | ||
| 204 | 259 | ||||
| 260 | + | // NewMockDiffFormatter creates a new instance of MockDiffFormatter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 261 | + | // The first argument is typically a *testing.T value. | |||
| 262 | + | func NewMockDiffFormatter(t interface { | |||
| 263 | + | mock.TestingT | |||
| 264 | + | Cleanup(func()) | |||
| 265 | + | }) *MockDiffFormatter { | |||
| 266 | + | mock := &MockDiffFormatter{} | |||
| 267 | + | mock.Mock.Test(t) | |||
| 268 | + | ||||
| 269 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 270 | + | ||||
| 271 | + | return mock | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // MockDiffFormatter is an autogenerated mock type for the DiffFormatter type | |||
| 275 | + | type MockDiffFormatter struct { | |||
| 276 | + | mock.Mock | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | type MockDiffFormatter_Expecter struct { | |||
| 280 | + | mock *mock.Mock | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func (_m *MockDiffFormatter) EXPECT() *MockDiffFormatter_Expecter { | |||
| 284 | + | return &MockDiffFormatter_Expecter{mock: &_m.Mock} | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // FormatChangesAsDiff provides a mock function for the type MockDiffFormatter | |||
| 288 | + | func (_mock *MockDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |||
| 289 | + | ret := _mock.Called(changes) | |||
| 290 | + | ||||
| 291 | + | if len(ret) == 0 { | |||
| 292 | + | panic("no return value specified for FormatChangesAsDiff") | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | var r0 string | |||
| 296 | + | if returnFunc, ok := ret.Get(0).(func([]port.KeyChange) string); ok { | |||
| 297 | + | r0 = returnFunc(changes) | |||
| 298 | + | } else { | |||
| 299 | + | r0 = ret.Get(0).(string) | |||
| 300 | + | } | |||
| 301 | + | return r0 | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // MockDiffFormatter_FormatChangesAsDiff_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FormatChangesAsDiff' | |||
| 305 | + | type MockDiffFormatter_FormatChangesAsDiff_Call struct { | |||
| 306 | + | *mock.Call | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // FormatChangesAsDiff is a helper method to define mock.On call | |||
| 310 | + | // - changes []port.KeyChange | |||
| 311 | + | func (_e *MockDiffFormatter_Expecter) FormatChangesAsDiff(changes interface{}) *MockDiffFormatter_FormatChangesAsDiff_Call { | |||
| 312 | + | return &MockDiffFormatter_FormatChangesAsDiff_Call{Call: _e.mock.On("FormatChangesAsDiff", changes)} | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Run(run func(changes []port.KeyChange)) *MockDiffFormatter_FormatChangesAsDiff_Call { | |||
| 316 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 317 | + | var arg0 []port.KeyChange | |||
| 318 | + | if args[0] != nil { | |||
| 319 | + | arg0 = args[0].([]port.KeyChange) | |||
| 320 | + | } | |||
| 321 | + | run( | |||
| 322 | + | arg0, | |||
| 323 | + | ) | |||
| 324 | + | }) | |||
| 325 | + | return _c | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Return(s string) *MockDiffFormatter_FormatChangesAsDiff_Call { | |||
| 329 | + | _c.Call.Return(s) | |||
| 330 | + | return _c | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(changes []port.KeyChange) string) *MockDiffFormatter_FormatChangesAsDiff_Call { | |||
| 334 | + | _c.Call.Return(run) | |||
| 335 | + | return _c | |||
| 336 | + | } | |||
| 337 | + | ||||
| 205 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 338 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 206 | // The first argument is typically a *testing.T value. | 339 | // The first argument is typically a *testing.T value. | ||
| 207 | func NewMockDatabaseProvider(t interface { | 340 | func NewMockDatabaseProvider(t interface { | ||
M internal/application/usecase/migrate_config.go +48 -3
| @@ -20,6 +20,19 @@ type CheckConfigMigrationOutput struct { | |||
| 20 | 20 | ConfigFile string | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | + | // DetectChangesInput holds the input for detecting config changes. | |
| 24 | + | type DetectChangesInput struct{} | |
| 25 | + | ||
| 26 | + | // DetectChangesOutput holds the result of change detection. | |
| 27 | + | type DetectChangesOutput struct { | |
| 28 | + | // HasChanges is true if any changes were detected. | |
| 29 | + | HasChanges bool | |
| 30 | + | // Changes contains all detected changes. | |
| 31 | + | Changes []port.KeyChange | |
| 32 | + | // DiffText is a formatted diff-like string representation. | |
| 33 | + | DiffText string | |
| 34 | + | } | |
| 35 | + | ||
| 23 | 36 | // MigrateConfigInput holds the input for migrating config. | |
| 24 | 37 | type MigrateConfigInput struct{} | |
| 25 | 38 | ||
| @@ -33,13 +46,15 @@ type MigrateConfigOutput struct { | |||
| 33 | 46 | ||
| 34 | 47 | // MigrateConfigUseCase handles config migration operations. | |
| 35 | 48 | type MigrateConfigUseCase struct { | |
| 36 | - | migrator port.ConfigMigrator | |
| 49 | + | migrator port.ConfigMigrator | |
| 50 | + | diffFormatter port.DiffFormatter | |
| 37 | 51 | } | |
| 38 | 52 | ||
| 39 | 53 | // NewMigrateConfigUseCase creates a new migrate config use case. | |
| 40 | - | func NewMigrateConfigUseCase(migrator port.ConfigMigrator) *MigrateConfigUseCase { | |
| 54 | + | func NewMigrateConfigUseCase(migrator port.ConfigMigrator, diffFormatter port.DiffFormatter) *MigrateConfigUseCase { | |
| 41 | 55 | return &MigrateConfigUseCase{ | |
| 42 | - | migrator: migrator, | |
| 56 | + | migrator: migrator, | |
| 57 | + | diffFormatter: diffFormatter, | |
| 43 | 58 | } | |
| 44 | 59 | } | |
| 45 | 60 | ||
| @@ -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: uc.diffFormatter.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) | |
| @@ -20,6 +20,19 @@ type CheckConfigMigrationOutput struct { | |||||
| 20 | ConfigFile string | 20 | ConfigFile string | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | + | // DetectChangesInput holds the input for detecting config changes. | |||
| 24 | + | type DetectChangesInput struct{} | |||
| 25 | + | ||||
| 26 | + | // DetectChangesOutput holds the result of change detection. | |||
| 27 | + | type DetectChangesOutput struct { | |||
| 28 | + | // HasChanges is true if any changes were detected. | |||
| 29 | + | HasChanges bool | |||
| 30 | + | // Changes contains all detected changes. | |||
| 31 | + | Changes []port.KeyChange | |||
| 32 | + | // DiffText is a formatted diff-like string representation. | |||
| 33 | + | DiffText string | |||
| 34 | + | } | |||
| 35 | + | ||||
| 23 | // MigrateConfigInput holds the input for migrating config. | 36 | // MigrateConfigInput holds the input for migrating config. | ||
| 24 | type MigrateConfigInput struct{} | 37 | type MigrateConfigInput struct{} | ||
| 25 | 38 | ||||
| @@ -33,13 +46,15 @@ type MigrateConfigOutput struct { | |||||
| 33 | 46 | ||||
| 34 | // MigrateConfigUseCase handles config migration operations. | 47 | // MigrateConfigUseCase handles config migration operations. | ||
| 35 | type MigrateConfigUseCase struct { | 48 | type MigrateConfigUseCase struct { | ||
| 36 | - | migrator port.ConfigMigrator | 49 | + | migrator port.ConfigMigrator |
| 50 | + | diffFormatter port.DiffFormatter | |||
| 37 | } | 51 | } | ||
| 38 | 52 | ||||
| 39 | // NewMigrateConfigUseCase creates a new migrate config use case. | 53 | // NewMigrateConfigUseCase creates a new migrate config use case. | ||
| 40 | - | func NewMigrateConfigUseCase(migrator port.ConfigMigrator) *MigrateConfigUseCase { | 54 | + | func NewMigrateConfigUseCase(migrator port.ConfigMigrator, diffFormatter port.DiffFormatter) *MigrateConfigUseCase { |
| 41 | return &MigrateConfigUseCase{ | 55 | return &MigrateConfigUseCase{ | ||
| 42 | - | migrator: migrator, | 56 | + | migrator: migrator, |
| 57 | + | diffFormatter: diffFormatter, | |||
| 43 | } | 58 | } | ||
| 44 | } | 59 | } | ||
| 45 | 60 | ||||
| @@ -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: uc.diffFormatter.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) | ||
M internal/application/usecase/migrate_config_test.go +176 -10
| @@ -15,9 +15,10 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) { | |
| 17 | 17 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 18 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 18 | 19 | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | |
| 19 | 20 | ||
| 20 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 21 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 21 | 22 | ctx := context.Background() | |
| 22 | 23 | ||
| 23 | 24 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| @@ -29,6 +30,7 @@ func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) { | |||
| 29 | 30 | ||
| 30 | 31 | func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | |
| 31 | 32 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 33 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 32 | 34 | ||
| 33 | 35 | migrationResult := &port.MigrationResult{ | |
| 34 | 36 | MissingKeys: []string{"key1", "key2"}, | |
| @@ -48,7 +50,7 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | |||
| 48 | 50 | DefaultValue: `"default"`, | |
| 49 | 51 | }) | |
| 50 | 52 | ||
| 51 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 53 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 52 | 54 | ctx := context.Background() | |
| 53 | 55 | ||
| 54 | 56 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| @@ -67,10 +69,11 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | |||
| 67 | 69 | ||
| 68 | 70 | func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | |
| 69 | 71 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 72 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 70 | 73 | expectedErr := errors.New("check failed") | |
| 71 | 74 | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | |
| 72 | 75 | ||
| 73 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 76 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 74 | 77 | ctx := context.Background() | |
| 75 | 78 | ||
| 76 | 79 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| @@ -82,9 +85,10 @@ func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | |||
| 82 | 85 | ||
| 83 | 86 | func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | |
| 84 | 87 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 88 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 85 | 89 | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | |
| 86 | 90 | ||
| 87 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 91 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 88 | 92 | ctx := context.Background() | |
| 89 | 93 | ||
| 90 | 94 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| @@ -95,6 +99,7 @@ func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | |||
| 95 | 99 | ||
| 96 | 100 | func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |
| 97 | 101 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 102 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 98 | 103 | ||
| 99 | 104 | migrationResult := &port.MigrationResult{ | |
| 100 | 105 | MissingKeys: []string{"key1", "key2"}, | |
| @@ -103,7 +108,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |||
| 103 | 108 | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | |
| 104 | 109 | mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil) | |
| 105 | 110 | ||
| 106 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 111 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 107 | 112 | ctx := context.Background() | |
| 108 | 113 | ||
| 109 | 114 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| @@ -115,6 +120,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |||
| 115 | 120 | ||
| 116 | 121 | func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |
| 117 | 122 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 123 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 118 | 124 | ||
| 119 | 125 | migrationResult := &port.MigrationResult{ | |
| 120 | 126 | MissingKeys: []string{"key1"}, | |
| @@ -125,7 +131,7 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||
| 125 | 131 | expectedErr := errors.New("migrate failed") | |
| 126 | 132 | mockMigrator.EXPECT().Migrate().Return(nil, expectedErr) | |
| 127 | 133 | ||
| 128 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 134 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 129 | 135 | ctx := context.Background() | |
| 130 | 136 | ||
| 131 | 137 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| @@ -137,11 +143,12 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||
| 137 | 143 | ||
| 138 | 144 | func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) { | |
| 139 | 145 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 146 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 140 | 147 | ||
| 141 | 148 | expectedErr := errors.New("check failed") | |
| 142 | 149 | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | |
| 143 | 150 | ||
| 144 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 151 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 145 | 152 | ctx := context.Background() | |
| 146 | 153 | ||
| 147 | 154 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| @@ -153,6 +160,7 @@ func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) { | |||
| 153 | 160 | ||
| 154 | 161 | func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | |
| 155 | 162 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 163 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 156 | 164 | ||
| 157 | 165 | // Return a result with empty missing keys | |
| 158 | 166 | migrationResult := &port.MigrationResult{ | |
| @@ -161,7 +169,7 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | |||
| 161 | 169 | } | |
| 162 | 170 | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | |
| 163 | 171 | ||
| 164 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 172 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 165 | 173 | ctx := context.Background() | |
| 166 | 174 | ||
| 167 | 175 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| @@ -173,15 +181,18 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | |||
| 173 | 181 | ||
| 174 | 182 | func TestNewMigrateConfigUseCase(t *testing.T) { | |
| 175 | 183 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 176 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 184 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 185 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 177 | 186 | ||
| 178 | 187 | assert.NotNil(t, uc) | |
| 179 | 188 | assert.Equal(t, mockMigrator, uc.migrator) | |
| 189 | + | assert.Equal(t, mockFormatter, uc.diffFormatter) | |
| 180 | 190 | } | |
| 181 | 191 | ||
| 182 | 192 | // Ensure mock expectations are set up correctly | |
| 183 | 193 | func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |
| 184 | 194 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 195 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 185 | 196 | ||
| 186 | 197 | // Set up expectations with mock.Anything for flexibility | |
| 187 | 198 | mockMigrator.On("CheckMigration").Return(&port.MigrationResult{ | |
| @@ -195,7 +206,7 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |||
| 195 | 206 | DefaultValue: `"test"`, | |
| 196 | 207 | }).Once() | |
| 197 | 208 | ||
| 198 | - | uc := NewMigrateConfigUseCase(mockMigrator) | |
| 209 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 199 | 210 | ctx := context.Background() | |
| 200 | 211 | ||
| 201 | 212 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| @@ -204,3 +215,158 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |||
| 204 | 215 | assert.True(t, result.NeedsMigration) | |
| 205 | 216 | mockMigrator.AssertExpectations(t) | |
| 206 | 217 | } | |
| 218 | + | ||
| 219 | + | func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) { | |
| 220 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 221 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 222 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) | |
| 223 | + | ||
| 224 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 225 | + | ctx := context.Background() | |
| 226 | + | ||
| 227 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 228 | + | ||
| 229 | + | require.NoError(t, err) | |
| 230 | + | assert.False(t, result.HasChanges) | |
| 231 | + | assert.Empty(t, result.Changes) | |
| 232 | + | assert.Equal(t, "No changes detected.", result.DiffText) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) { | |
| 236 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 237 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 238 | + | ||
| 239 | + | changes := []port.KeyChange{ | |
| 240 | + | { | |
| 241 | + | Type: port.KeyChangeAdded, | |
| 242 | + | NewKey: "workspace.styling.mode_indicator_toaster_enabled", | |
| 243 | + | NewValue: "true", | |
| 244 | + | }, | |
| 245 | + | } | |
| 246 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 247 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n + workspace.styling.mode_indicator_toaster_enabled = true\n") | |
| 248 | + | ||
| 249 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 250 | + | ctx := context.Background() | |
| 251 | + | ||
| 252 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 253 | + | ||
| 254 | + | require.NoError(t, err) | |
| 255 | + | assert.True(t, result.HasChanges) | |
| 256 | + | assert.Len(t, result.Changes, 1) | |
| 257 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled") | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) { | |
| 261 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 262 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 263 | + | ||
| 264 | + | changes := []port.KeyChange{ | |
| 265 | + | { | |
| 266 | + | Type: port.KeyChangeRenamed, | |
| 267 | + | OldKey: "workspace.styling.pane_mode_border_color", | |
| 268 | + | NewKey: "workspace.styling.pane_mode_color", | |
| 269 | + | OldValue: `"#4A90E2"`, | |
| 270 | + | }, | |
| 271 | + | } | |
| 272 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 273 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n (value: \"#4A90E2\")\n") | |
| 274 | + | ||
| 275 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 276 | + | ctx := context.Background() | |
| 277 | + | ||
| 278 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 279 | + | ||
| 280 | + | require.NoError(t, err) | |
| 281 | + | assert.True(t, result.HasChanges) | |
| 282 | + | assert.Len(t, result.Changes, 1) | |
| 283 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color") | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) { | |
| 287 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 288 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 289 | + | ||
| 290 | + | changes := []port.KeyChange{ | |
| 291 | + | { | |
| 292 | + | Type: port.KeyChangeRemoved, | |
| 293 | + | OldKey: "workspace.styling.pane_mode_border_width", | |
| 294 | + | OldValue: "4", | |
| 295 | + | }, | |
| 296 | + | } | |
| 297 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 298 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n - workspace.styling.pane_mode_border_width = 4 (deprecated)\n") | |
| 299 | + | ||
| 300 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 301 | + | ctx := context.Background() | |
| 302 | + | ||
| 303 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 304 | + | ||
| 305 | + | require.NoError(t, err) | |
| 306 | + | assert.True(t, result.HasChanges) | |
| 307 | + | assert.Len(t, result.Changes, 1) | |
| 308 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |
| 309 | + | assert.Contains(t, result.DiffText, "(deprecated)") | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) { | |
| 313 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 314 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 315 | + | expectedErr := errors.New("detect changes failed") | |
| 316 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) | |
| 317 | + | ||
| 318 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 319 | + | ctx := context.Background() | |
| 320 | + | ||
| 321 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 322 | + | ||
| 323 | + | require.Error(t, err) | |
| 324 | + | assert.Nil(t, result) | |
| 325 | + | assert.Equal(t, expectedErr, err) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) { | |
| 329 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 330 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 331 | + | ||
| 332 | + | changes := []port.KeyChange{ | |
| 333 | + | { | |
| 334 | + | Type: port.KeyChangeAdded, | |
| 335 | + | NewKey: "workspace.styling.mode_border_width", | |
| 336 | + | NewValue: "4", | |
| 337 | + | }, | |
| 338 | + | { | |
| 339 | + | Type: port.KeyChangeRenamed, | |
| 340 | + | OldKey: "workspace.styling.pane_mode_border_color", | |
| 341 | + | NewKey: "workspace.styling.pane_mode_color", | |
| 342 | + | OldValue: `"#4A90E2"`, | |
| 343 | + | }, | |
| 344 | + | { | |
| 345 | + | Type: port.KeyChangeRemoved, | |
| 346 | + | OldKey: "workspace.styling.pane_mode_border_width", | |
| 347 | + | OldValue: "4", | |
| 348 | + | }, | |
| 349 | + | } | |
| 350 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 351 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return( | |
| 352 | + | "Config migration changes:\n\n" + | |
| 353 | + | " + workspace.styling.mode_border_width = 4\n" + | |
| 354 | + | " ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n" + | |
| 355 | + | " (value: \"#4A90E2\")\n" + | |
| 356 | + | " - workspace.styling.pane_mode_border_width = 4 (deprecated)\n", | |
| 357 | + | ) | |
| 358 | + | ||
| 359 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 360 | + | ctx := context.Background() | |
| 361 | + | ||
| 362 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 363 | + | ||
| 364 | + | require.NoError(t, err) | |
| 365 | + | assert.True(t, result.HasChanges) | |
| 366 | + | assert.Len(t, result.Changes, 3) | |
| 367 | + | ||
| 368 | + | // Verify diff contains all change types | |
| 369 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width") | |
| 370 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color") | |
| 371 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |
| 372 | + | } | |
| @@ -15,9 +15,10 @@ import ( | |||||
| 15 | 15 | ||||
| 16 | func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) { | 16 | func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) { | ||
| 17 | mockMigrator := mocks.NewMockConfigMigrator(t) | 17 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 18 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 18 | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | 19 | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | ||
| 19 | 20 | ||||
| 20 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 21 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 21 | ctx := context.Background() | 22 | ctx := context.Background() | ||
| 22 | 23 | ||||
| 23 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | 24 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | ||
| @@ -29,6 +30,7 @@ func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) { | |||||
| 29 | 30 | ||||
| 30 | func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | 31 | func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | ||
| 31 | mockMigrator := mocks.NewMockConfigMigrator(t) | 32 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 33 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 32 | 34 | ||||
| 33 | migrationResult := &port.MigrationResult{ | 35 | migrationResult := &port.MigrationResult{ | ||
| 34 | MissingKeys: []string{"key1", "key2"}, | 36 | MissingKeys: []string{"key1", "key2"}, | ||
| @@ -48,7 +50,7 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | |||||
| 48 | DefaultValue: `"default"`, | 50 | DefaultValue: `"default"`, | ||
| 49 | }) | 51 | }) | ||
| 50 | 52 | ||||
| 51 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 53 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 52 | ctx := context.Background() | 54 | ctx := context.Background() | ||
| 53 | 55 | ||||
| 54 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | 56 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | ||
| @@ -67,10 +69,11 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | |||||
| 67 | 69 | ||||
| 68 | func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | 70 | func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | ||
| 69 | mockMigrator := mocks.NewMockConfigMigrator(t) | 71 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 72 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 70 | expectedErr := errors.New("check failed") | 73 | expectedErr := errors.New("check failed") | ||
| 71 | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | 74 | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | ||
| 72 | 75 | ||||
| 73 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 76 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 74 | ctx := context.Background() | 77 | ctx := context.Background() | ||
| 75 | 78 | ||||
| 76 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | 79 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | ||
| @@ -82,9 +85,10 @@ func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | |||||
| 82 | 85 | ||||
| 83 | func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | 86 | func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | ||
| 84 | mockMigrator := mocks.NewMockConfigMigrator(t) | 87 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 88 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 85 | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | 89 | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | ||
| 86 | 90 | ||||
| 87 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 91 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 88 | ctx := context.Background() | 92 | ctx := context.Background() | ||
| 89 | 93 | ||||
| 90 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | 94 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | ||
| @@ -95,6 +99,7 @@ func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | |||||
| 95 | 99 | ||||
| 96 | func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | 100 | func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | ||
| 97 | mockMigrator := mocks.NewMockConfigMigrator(t) | 101 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 102 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 98 | 103 | ||||
| 99 | migrationResult := &port.MigrationResult{ | 104 | migrationResult := &port.MigrationResult{ | ||
| 100 | MissingKeys: []string{"key1", "key2"}, | 105 | MissingKeys: []string{"key1", "key2"}, | ||
| @@ -103,7 +108,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |||||
| 103 | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | 108 | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | ||
| 104 | mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil) | 109 | mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil) | ||
| 105 | 110 | ||||
| 106 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 111 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 107 | ctx := context.Background() | 112 | ctx := context.Background() | ||
| 108 | 113 | ||||
| 109 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | 114 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | ||
| @@ -115,6 +120,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |||||
| 115 | 120 | ||||
| 116 | func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | 121 | func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | ||
| 117 | mockMigrator := mocks.NewMockConfigMigrator(t) | 122 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 123 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 118 | 124 | ||||
| 119 | migrationResult := &port.MigrationResult{ | 125 | migrationResult := &port.MigrationResult{ | ||
| 120 | MissingKeys: []string{"key1"}, | 126 | MissingKeys: []string{"key1"}, | ||
| @@ -125,7 +131,7 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||||
| 125 | expectedErr := errors.New("migrate failed") | 131 | expectedErr := errors.New("migrate failed") | ||
| 126 | mockMigrator.EXPECT().Migrate().Return(nil, expectedErr) | 132 | mockMigrator.EXPECT().Migrate().Return(nil, expectedErr) | ||
| 127 | 133 | ||||
| 128 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 134 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 129 | ctx := context.Background() | 135 | ctx := context.Background() | ||
| 130 | 136 | ||||
| 131 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | 137 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | ||
| @@ -137,11 +143,12 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||||
| 137 | 143 | ||||
| 138 | func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) { | 144 | func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) { | ||
| 139 | mockMigrator := mocks.NewMockConfigMigrator(t) | 145 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 146 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 140 | 147 | ||||
| 141 | expectedErr := errors.New("check failed") | 148 | expectedErr := errors.New("check failed") | ||
| 142 | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | 149 | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | ||
| 143 | 150 | ||||
| 144 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 151 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 145 | ctx := context.Background() | 152 | ctx := context.Background() | ||
| 146 | 153 | ||||
| 147 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | 154 | result, err := uc.Execute(ctx, MigrateConfigInput{}) | ||
| @@ -153,6 +160,7 @@ func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) { | |||||
| 153 | 160 | ||||
| 154 | func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | 161 | func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | ||
| 155 | mockMigrator := mocks.NewMockConfigMigrator(t) | 162 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 163 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 156 | 164 | ||||
| 157 | // Return a result with empty missing keys | 165 | // Return a result with empty missing keys | ||
| 158 | migrationResult := &port.MigrationResult{ | 166 | migrationResult := &port.MigrationResult{ | ||
| @@ -161,7 +169,7 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | |||||
| 161 | } | 169 | } | ||
| 162 | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | 170 | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | ||
| 163 | 171 | ||||
| 164 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 172 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 165 | ctx := context.Background() | 173 | ctx := context.Background() | ||
| 166 | 174 | ||||
| 167 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | 175 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | ||
| @@ -173,15 +181,18 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | |||||
| 173 | 181 | ||||
| 174 | func TestNewMigrateConfigUseCase(t *testing.T) { | 182 | func TestNewMigrateConfigUseCase(t *testing.T) { | ||
| 175 | mockMigrator := mocks.NewMockConfigMigrator(t) | 183 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 176 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 184 | + | mockFormatter := mocks.NewMockDiffFormatter(t) |
| 185 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |||
| 177 | 186 | ||||
| 178 | assert.NotNil(t, uc) | 187 | assert.NotNil(t, uc) | ||
| 179 | assert.Equal(t, mockMigrator, uc.migrator) | 188 | assert.Equal(t, mockMigrator, uc.migrator) | ||
| 189 | + | assert.Equal(t, mockFormatter, uc.diffFormatter) | |||
| 180 | } | 190 | } | ||
| 181 | 191 | ||||
| 182 | // Ensure mock expectations are set up correctly | 192 | // Ensure mock expectations are set up correctly | ||
| 183 | func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | 193 | func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | ||
| 184 | mockMigrator := mocks.NewMockConfigMigrator(t) | 194 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 195 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 185 | 196 | ||||
| 186 | // Set up expectations with mock.Anything for flexibility | 197 | // Set up expectations with mock.Anything for flexibility | ||
| 187 | mockMigrator.On("CheckMigration").Return(&port.MigrationResult{ | 198 | mockMigrator.On("CheckMigration").Return(&port.MigrationResult{ | ||
| @@ -195,7 +206,7 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |||||
| 195 | DefaultValue: `"test"`, | 206 | DefaultValue: `"test"`, | ||
| 196 | }).Once() | 207 | }).Once() | ||
| 197 | 208 | ||||
| 198 | - | uc := NewMigrateConfigUseCase(mockMigrator) | 209 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) |
| 199 | ctx := context.Background() | 210 | ctx := context.Background() | ||
| 200 | 211 | ||||
| 201 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | 212 | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | ||
| @@ -204,3 +215,158 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |||||
| 204 | assert.True(t, result.NeedsMigration) | 215 | assert.True(t, result.NeedsMigration) | ||
| 205 | mockMigrator.AssertExpectations(t) | 216 | mockMigrator.AssertExpectations(t) | ||
| 206 | } | 217 | } | ||
| 218 | + | ||||
| 219 | + | func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) { | |||
| 220 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 221 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 222 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) | |||
| 223 | + | ||||
| 224 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |||
| 225 | + | ctx := context.Background() | |||
| 226 | + | ||||
| 227 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 228 | + | ||||
| 229 | + | require.NoError(t, err) | |||
| 230 | + | assert.False(t, result.HasChanges) | |||
| 231 | + | assert.Empty(t, result.Changes) | |||
| 232 | + | assert.Equal(t, "No changes detected.", result.DiffText) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) { | |||
| 236 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 237 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 238 | + | ||||
| 239 | + | changes := []port.KeyChange{ | |||
| 240 | + | { | |||
| 241 | + | Type: port.KeyChangeAdded, | |||
| 242 | + | NewKey: "workspace.styling.mode_indicator_toaster_enabled", | |||
| 243 | + | NewValue: "true", | |||
| 244 | + | }, | |||
| 245 | + | } | |||
| 246 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 247 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n + workspace.styling.mode_indicator_toaster_enabled = true\n") | |||
| 248 | + | ||||
| 249 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |||
| 250 | + | ctx := context.Background() | |||
| 251 | + | ||||
| 252 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 253 | + | ||||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | assert.True(t, result.HasChanges) | |||
| 256 | + | assert.Len(t, result.Changes, 1) | |||
| 257 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled") | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) { | |||
| 261 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 262 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 263 | + | ||||
| 264 | + | changes := []port.KeyChange{ | |||
| 265 | + | { | |||
| 266 | + | Type: port.KeyChangeRenamed, | |||
| 267 | + | OldKey: "workspace.styling.pane_mode_border_color", | |||
| 268 | + | NewKey: "workspace.styling.pane_mode_color", | |||
| 269 | + | OldValue: `"#4A90E2"`, | |||
| 270 | + | }, | |||
| 271 | + | } | |||
| 272 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 273 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n (value: \"#4A90E2\")\n") | |||
| 274 | + | ||||
| 275 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |||
| 276 | + | ctx := context.Background() | |||
| 277 | + | ||||
| 278 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 279 | + | ||||
| 280 | + | require.NoError(t, err) | |||
| 281 | + | assert.True(t, result.HasChanges) | |||
| 282 | + | assert.Len(t, result.Changes, 1) | |||
| 283 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color") | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) { | |||
| 287 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 288 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 289 | + | ||||
| 290 | + | changes := []port.KeyChange{ | |||
| 291 | + | { | |||
| 292 | + | Type: port.KeyChangeRemoved, | |||
| 293 | + | OldKey: "workspace.styling.pane_mode_border_width", | |||
| 294 | + | OldValue: "4", | |||
| 295 | + | }, | |||
| 296 | + | } | |||
| 297 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 298 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n - workspace.styling.pane_mode_border_width = 4 (deprecated)\n") | |||
| 299 | + | ||||
| 300 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |||
| 301 | + | ctx := context.Background() | |||
| 302 | + | ||||
| 303 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 304 | + | ||||
| 305 | + | require.NoError(t, err) | |||
| 306 | + | assert.True(t, result.HasChanges) | |||
| 307 | + | assert.Len(t, result.Changes, 1) | |||
| 308 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |||
| 309 | + | assert.Contains(t, result.DiffText, "(deprecated)") | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) { | |||
| 313 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 314 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 315 | + | expectedErr := errors.New("detect changes failed") | |||
| 316 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) | |||
| 317 | + | ||||
| 318 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |||
| 319 | + | ctx := context.Background() | |||
| 320 | + | ||||
| 321 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 322 | + | ||||
| 323 | + | require.Error(t, err) | |||
| 324 | + | assert.Nil(t, result) | |||
| 325 | + | assert.Equal(t, expectedErr, err) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) { | |||
| 329 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |||
| 330 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |||
| 331 | + | ||||
| 332 | + | changes := []port.KeyChange{ | |||
| 333 | + | { | |||
| 334 | + | Type: port.KeyChangeAdded, | |||
| 335 | + | NewKey: "workspace.styling.mode_border_width", | |||
| 336 | + | NewValue: "4", | |||
| 337 | + | }, | |||
| 338 | + | { | |||
| 339 | + | Type: port.KeyChangeRenamed, | |||
| 340 | + | OldKey: "workspace.styling.pane_mode_border_color", | |||
| 341 | + | NewKey: "workspace.styling.pane_mode_color", | |||
| 342 | + | OldValue: `"#4A90E2"`, | |||
| 343 | + | }, | |||
| 344 | + | { | |||
| 345 | + | Type: port.KeyChangeRemoved, | |||
| 346 | + | OldKey: "workspace.styling.pane_mode_border_width", | |||
| 347 | + | OldValue: "4", | |||
| 348 | + | }, | |||
| 349 | + | } | |||
| 350 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |||
| 351 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return( | |||
| 352 | + | "Config migration changes:\n\n" + | |||
| 353 | + | " + workspace.styling.mode_border_width = 4\n" + | |||
| 354 | + | " ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n" + | |||
| 355 | + | " (value: \"#4A90E2\")\n" + | |||
| 356 | + | " - workspace.styling.pane_mode_border_width = 4 (deprecated)\n", | |||
| 357 | + | ) | |||
| 358 | + | ||||
| 359 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |||
| 360 | + | ctx := context.Background() | |||
| 361 | + | ||||
| 362 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |||
| 363 | + | ||||
| 364 | + | require.NoError(t, err) | |||
| 365 | + | assert.True(t, result.HasChanges) | |||
| 366 | + | assert.Len(t, result.Changes, 3) | |||
| 367 | + | ||||
| 368 | + | // Verify diff contains all change types | |||
| 369 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width") | |||
| 370 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color") | |||
| 371 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |||
| 372 | + | } | |||
M internal/cli/cmd/config.go +28 -25
| @@ -56,7 +56,8 @@ func runConfigStatus(_ *cobra.Command, _ []string) error { | |||
| 56 | 56 | ||
| 57 | 57 | renderer := styles.NewConfigRenderer(app.Theme) | |
| 58 | 58 | migrator := config.NewMigrator() | |
| 59 | - | uc := usecase.NewMigrateConfigUseCase(migrator) | |
| 59 | + | diffFormatter := config.NewDiffFormatter() | |
| 60 | + | uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter) | |
| 60 | 61 | ||
| 61 | 62 | // Get config file path | |
| 62 | 63 | configFile, err := config.GetConfigFile() | |
| @@ -100,7 +101,8 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error { | |||
| 100 | 101 | ||
| 101 | 102 | renderer := styles.NewConfigRenderer(app.Theme) | |
| 102 | 103 | migrator := config.NewMigrator() | |
| 103 | - | uc := usecase.NewMigrateConfigUseCase(migrator) | |
| 104 | + | diffFormatter := config.NewDiffFormatter() | |
| 105 | + | uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter) | |
| 104 | 106 | ||
| 105 | 107 | // Get config file path | |
| 106 | 108 | configFile, err := config.GetConfigFile() | |
| @@ -115,22 +117,23 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error { | |||
| 115 | 117 | return nil | |
| 116 | 118 | } | |
| 117 | 119 | ||
| 118 | - | // Check for migration | |
| 120 | + | // Detect changes (includes renames, additions, and removals) | |
| 119 | 121 | ctx := context.Background() | |
| 120 | - | result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{}) | |
| 122 | + | detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{}) | |
| 121 | 123 | if err != nil { | |
| 122 | 124 | fmt.Println(renderer.RenderError(err)) | |
| 123 | 125 | return nil | |
| 124 | 126 | } | |
| 125 | 127 | ||
| 126 | - | if !result.NeedsMigration { | |
| 128 | + | if !detectResult.HasChanges { | |
| 127 | 129 | fmt.Println(renderer.RenderUpToDate(configFile)) | |
| 128 | 130 | return nil | |
| 129 | 131 | } | |
| 130 | 132 | ||
| 131 | - | // Show config info and missing keys | |
| 132 | - | fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys))) | |
| 133 | - | fmt.Println(renderer.RenderMissingKeys(result.MissingKeys)) | |
| 133 | + | // Show config info with change summary | |
| 134 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes))) | |
| 135 | + | fmt.Println(renderer.RenderChangesSummary(detectResult.Changes)) | |
| 136 | + | fmt.Println(renderer.RenderChanges(detectResult.Changes)) | |
| 134 | 137 | ||
| 135 | 138 | // If --yes flag, proceed without confirmation | |
| 136 | 139 | if configYes { | |
| @@ -138,7 +141,7 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error { | |||
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | // Show confirmation dialog | |
| 141 | - | return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys) | |
| 144 | + | return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes) | |
| 142 | 145 | } | |
| 143 | 146 | ||
| 144 | 147 | // executeMigration performs the actual migration. | |
| @@ -166,12 +169,12 @@ const ( | |||
| 166 | 169 | ||
| 167 | 170 | // migrateModel is the bubbletea model for the migrate confirmation. | |
| 168 | 171 | type migrateModel struct { | |
| 169 | - | spinner spinner.Model | |
| 170 | - | renderer *styles.ConfigRenderer | |
| 171 | - | confirm styles.ConfirmModel | |
| 172 | - | state migrateState | |
| 173 | - | uc *usecase.MigrateConfigUseCase | |
| 174 | - | missingKeys []port.KeyInfo | |
| 172 | + | spinner spinner.Model | |
| 173 | + | renderer *styles.ConfigRenderer | |
| 174 | + | confirm styles.ConfirmModel | |
| 175 | + | state migrateState | |
| 176 | + | uc *usecase.MigrateConfigUseCase | |
| 177 | + | changes []port.KeyChange | |
| 175 | 178 | ||
| 176 | 179 | result string | |
| 177 | 180 | err error | |
| @@ -188,21 +191,21 @@ func newMigrateModel( | |||
| 188 | 191 | renderer *styles.ConfigRenderer, | |
| 189 | 192 | theme *styles.Theme, | |
| 190 | 193 | uc *usecase.MigrateConfigUseCase, | |
| 191 | - | missingKeys []port.KeyInfo, | |
| 194 | + | changes []port.KeyChange, | |
| 192 | 195 | ) migrateModel { | |
| 193 | 196 | s := spinner.New() | |
| 194 | 197 | s.Spinner = spinner.Dot | |
| 195 | 198 | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 196 | 199 | ||
| 197 | - | confirm := styles.NewConfirm(theme, "Add these settings with default values?") | |
| 200 | + | confirm := styles.NewConfirm(theme, "Apply these changes?") | |
| 198 | 201 | ||
| 199 | 202 | return migrateModel{ | |
| 200 | - | spinner: s, | |
| 201 | - | renderer: renderer, | |
| 202 | - | confirm: confirm, | |
| 203 | - | state: migrateStateConfirm, | |
| 204 | - | uc: uc, | |
| 205 | - | missingKeys: missingKeys, | |
| 203 | + | spinner: s, | |
| 204 | + | renderer: renderer, | |
| 205 | + | confirm: confirm, | |
| 206 | + | state: migrateStateConfirm, | |
| 207 | + | uc: uc, | |
| 208 | + | changes: changes, | |
| 206 | 209 | } | |
| 207 | 210 | } | |
| 208 | 211 | ||
| @@ -289,12 +292,12 @@ func runMigrateWithConfirmation( | |||
| 289 | 292 | uc *usecase.MigrateConfigUseCase, | |
| 290 | 293 | renderer *styles.ConfigRenderer, | |
| 291 | 294 | theme *styles.Theme, | |
| 292 | - | missingKeys []port.KeyInfo, | |
| 295 | + | changes []port.KeyChange, | |
| 293 | 296 | ) error { | |
| 294 | 297 | // Suppress unused ctx warning - kept for consistency with other run functions | |
| 295 | 298 | _ = ctx | |
| 296 | 299 | ||
| 297 | - | m := newMigrateModel(renderer, theme, uc, missingKeys) | |
| 300 | + | m := newMigrateModel(renderer, theme, uc, changes) | |
| 298 | 301 | p := tea.NewProgram(m) | |
| 299 | 302 | ||
| 300 | 303 | _, err := p.Run() | |
| @@ -56,7 +56,8 @@ func runConfigStatus(_ *cobra.Command, _ []string) error { | |||||
| 56 | 56 | ||||
| 57 | renderer := styles.NewConfigRenderer(app.Theme) | 57 | renderer := styles.NewConfigRenderer(app.Theme) | ||
| 58 | migrator := config.NewMigrator() | 58 | migrator := config.NewMigrator() | ||
| 59 | - | uc := usecase.NewMigrateConfigUseCase(migrator) | 59 | + | diffFormatter := config.NewDiffFormatter() |
| 60 | + | uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter) | |||
| 60 | 61 | ||||
| 61 | // Get config file path | 62 | // Get config file path | ||
| 62 | configFile, err := config.GetConfigFile() | 63 | configFile, err := config.GetConfigFile() | ||
| @@ -100,7 +101,8 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error { | |||||
| 100 | 101 | ||||
| 101 | renderer := styles.NewConfigRenderer(app.Theme) | 102 | renderer := styles.NewConfigRenderer(app.Theme) | ||
| 102 | migrator := config.NewMigrator() | 103 | migrator := config.NewMigrator() | ||
| 103 | - | uc := usecase.NewMigrateConfigUseCase(migrator) | 104 | + | diffFormatter := config.NewDiffFormatter() |
| 105 | + | uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter) | |||
| 104 | 106 | ||||
| 105 | // Get config file path | 107 | // Get config file path | ||
| 106 | configFile, err := config.GetConfigFile() | 108 | configFile, err := config.GetConfigFile() | ||
| @@ -115,22 +117,23 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error { | |||||
| 115 | return nil | 117 | return nil | ||
| 116 | } | 118 | } | ||
| 117 | 119 | ||||
| 118 | - | // Check for migration | 120 | + | // Detect changes (includes renames, additions, and removals) |
| 119 | ctx := context.Background() | 121 | ctx := context.Background() | ||
| 120 | - | result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{}) | 122 | + | detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{}) |
| 121 | if err != nil { | 123 | if err != nil { | ||
| 122 | fmt.Println(renderer.RenderError(err)) | 124 | fmt.Println(renderer.RenderError(err)) | ||
| 123 | return nil | 125 | return nil | ||
| 124 | } | 126 | } | ||
| 125 | 127 | ||||
| 126 | - | if !result.NeedsMigration { | 128 | + | if !detectResult.HasChanges { |
| 127 | fmt.Println(renderer.RenderUpToDate(configFile)) | 129 | fmt.Println(renderer.RenderUpToDate(configFile)) | ||
| 128 | return nil | 130 | return nil | ||
| 129 | } | 131 | } | ||
| 130 | 132 | ||||
| 131 | - | // Show config info and missing keys | 133 | + | // Show config info with change summary |
| 132 | - | fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys))) | 134 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes))) |
| 133 | - | fmt.Println(renderer.RenderMissingKeys(result.MissingKeys)) | 135 | + | fmt.Println(renderer.RenderChangesSummary(detectResult.Changes)) |
| 136 | + | fmt.Println(renderer.RenderChanges(detectResult.Changes)) | |||
| 134 | 137 | ||||
| 135 | // If --yes flag, proceed without confirmation | 138 | // If --yes flag, proceed without confirmation | ||
| 136 | if configYes { | 139 | if configYes { | ||
| @@ -138,7 +141,7 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error { | |||||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 140 | // Show confirmation dialog | 143 | // Show confirmation dialog | ||
| 141 | - | return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys) | 144 | + | return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes) |
| 142 | } | 145 | } | ||
| 143 | 146 | ||||
| 144 | // executeMigration performs the actual migration. | 147 | // executeMigration performs the actual migration. | ||
| @@ -166,12 +169,12 @@ const ( | |||||
| 166 | 169 | ||||
| 167 | // migrateModel is the bubbletea model for the migrate confirmation. | 170 | // migrateModel is the bubbletea model for the migrate confirmation. | ||
| 168 | type migrateModel struct { | 171 | type migrateModel struct { | ||
| 169 | - | spinner spinner.Model | 172 | + | spinner spinner.Model |
| 170 | - | renderer *styles.ConfigRenderer | 173 | + | renderer *styles.ConfigRenderer |
| 171 | - | confirm styles.ConfirmModel | 174 | + | confirm styles.ConfirmModel |
| 172 | - | state migrateState | 175 | + | state migrateState |
| 173 | - | uc *usecase.MigrateConfigUseCase | 176 | + | uc *usecase.MigrateConfigUseCase |
| 174 | - | missingKeys []port.KeyInfo | 177 | + | changes []port.KeyChange |
| 175 | 178 | ||||
| 176 | result string | 179 | result string | ||
| 177 | err error | 180 | err error | ||
| @@ -188,21 +191,21 @@ func newMigrateModel( | |||||
| 188 | renderer *styles.ConfigRenderer, | 191 | renderer *styles.ConfigRenderer, | ||
| 189 | theme *styles.Theme, | 192 | theme *styles.Theme, | ||
| 190 | uc *usecase.MigrateConfigUseCase, | 193 | uc *usecase.MigrateConfigUseCase, | ||
| 191 | - | missingKeys []port.KeyInfo, | 194 | + | changes []port.KeyChange, |
| 192 | ) migrateModel { | 195 | ) migrateModel { | ||
| 193 | s := spinner.New() | 196 | s := spinner.New() | ||
| 194 | s.Spinner = spinner.Dot | 197 | s.Spinner = spinner.Dot | ||
| 195 | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | 198 | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | ||
| 196 | 199 | ||||
| 197 | - | confirm := styles.NewConfirm(theme, "Add these settings with default values?") | 200 | + | confirm := styles.NewConfirm(theme, "Apply these changes?") |
| 198 | 201 | ||||
| 199 | return migrateModel{ | 202 | return migrateModel{ | ||
| 200 | - | spinner: s, | 203 | + | spinner: s, |
| 201 | - | renderer: renderer, | 204 | + | renderer: renderer, |
| 202 | - | confirm: confirm, | 205 | + | confirm: confirm, |
| 203 | - | state: migrateStateConfirm, | 206 | + | state: migrateStateConfirm, |
| 204 | - | uc: uc, | 207 | + | uc: uc, |
| 205 | - | missingKeys: missingKeys, | 208 | + | changes: changes, |
| 206 | } | 209 | } | ||
| 207 | } | 210 | } | ||
| 208 | 211 | ||||
| @@ -289,12 +292,12 @@ func runMigrateWithConfirmation( | |||||
| 289 | uc *usecase.MigrateConfigUseCase, | 292 | uc *usecase.MigrateConfigUseCase, | ||
| 290 | renderer *styles.ConfigRenderer, | 293 | renderer *styles.ConfigRenderer, | ||
| 291 | theme *styles.Theme, | 294 | theme *styles.Theme, | ||
| 292 | - | missingKeys []port.KeyInfo, | 295 | + | changes []port.KeyChange, |
| 293 | ) error { | 296 | ) error { | ||
| 294 | // Suppress unused ctx warning - kept for consistency with other run functions | 297 | // Suppress unused ctx warning - kept for consistency with other run functions | ||
| 295 | _ = ctx | 298 | _ = ctx | ||
| 296 | 299 | ||||
| 297 | - | m := newMigrateModel(renderer, theme, uc, missingKeys) | 300 | + | m := newMigrateModel(renderer, theme, uc, changes) |
| 298 | p := tea.NewProgram(m) | 301 | p := tea.NewProgram(m) | ||
| 299 | 302 | ||||
| 300 | _, err := p.Run() | 303 | _, err := p.Run() | ||
M internal/cli/styles/config.go +113 -5
| @@ -21,16 +21,16 @@ func NewConfigRenderer(theme *Theme) *ConfigRenderer { | |||
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | // RenderConfigInfo renders the config file info with status. | |
| 24 | - | func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string { | |
| 24 | + | func (r *ConfigRenderer) RenderConfigInfo(path string, changeCount int) string { | |
| 25 | 25 | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 26 | 26 | pathStyle := r.theme.Subtle | |
| 27 | 27 | ||
| 28 | 28 | var status string | |
| 29 | - | if missingCount > 0 { | |
| 29 | + | if changeCount > 0 { | |
| 30 | 30 | countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 31 | - | status = fmt.Sprintf("\n %s %s new settings available", | |
| 31 | + | status = fmt.Sprintf("\n %s %s changes detected", | |
| 32 | 32 | iconStyle.Render(IconInfo), | |
| 33 | - | countStyle.Render(fmt.Sprintf("%d", missingCount)), | |
| 33 | + | countStyle.Render(fmt.Sprintf("%d", changeCount)), | |
| 34 | 34 | ) | |
| 35 | 35 | } | |
| 36 | 36 | ||
| @@ -69,6 +69,114 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |||
| 69 | 69 | return sb.String() | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | // RenderChanges renders the list of detected changes (added, renamed, removed). | |
| 73 | + | func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string { | |
| 74 | + | if len(changes) == 0 { | |
| 75 | + | return "" | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | addStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 79 | + | renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 80 | + | removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 81 | + | keyStyle := r.theme.Highlight | |
| 82 | + | valueStyle := r.theme.Subtle | |
| 83 | + | ||
| 84 | + | // Group changes by type | |
| 85 | + | var added, renamed, removed []port.KeyChange | |
| 86 | + | for _, c := range changes { | |
| 87 | + | switch c.Type { | |
| 88 | + | case port.KeyChangeAdded: | |
| 89 | + | added = append(added, c) | |
| 90 | + | case port.KeyChangeRenamed: | |
| 91 | + | renamed = append(renamed, c) | |
| 92 | + | case port.KeyChangeRemoved: | |
| 93 | + | removed = append(removed, c) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | var sb strings.Builder | |
| 98 | + | ||
| 99 | + | // Show renamed keys first (most important for user to understand) | |
| 100 | + | if len(renamed) > 0 { | |
| 101 | + | sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed))) | |
| 102 | + | for _, c := range renamed { | |
| 103 | + | sb.WriteString(fmt.Sprintf( | |
| 104 | + | " %s %s %s %s\n Value: %s\n", | |
| 105 | + | renameStyle.Render("~"), | |
| 106 | + | valueStyle.Render(c.OldKey), | |
| 107 | + | renameStyle.Render("→"), | |
| 108 | + | keyStyle.Render(c.NewKey), | |
| 109 | + | valueStyle.Render(c.OldValue), | |
| 110 | + | )) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Show new settings | |
| 115 | + | if len(added) > 0 { | |
| 116 | + | sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added))) | |
| 117 | + | for _, c := range added { | |
| 118 | + | sb.WriteString(fmt.Sprintf( | |
| 119 | + | " %s %s\n Default: %s\n", | |
| 120 | + | addStyle.Render("+"), | |
| 121 | + | keyStyle.Render(c.NewKey), | |
| 122 | + | valueStyle.Render(c.NewValue), | |
| 123 | + | )) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // Show deprecated settings (will be removed) | |
| 128 | + | if len(removed) > 0 { | |
| 129 | + | sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed))) | |
| 130 | + | for _, c := range removed { | |
| 131 | + | sb.WriteString(fmt.Sprintf( | |
| 132 | + | " %s %s %s\n", | |
| 133 | + | removeStyle.Render("-"), | |
| 134 | + | valueStyle.Render(c.OldKey), | |
| 135 | + | removeStyle.Render("(will be removed)"), | |
| 136 | + | )) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | return sb.String() | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // RenderChangesSummary renders a summary of changes for the config info header. | |
| 144 | + | func (r *ConfigRenderer) RenderChangesSummary(changes []port.KeyChange) string { | |
| 145 | + | var added, renamed, removed int | |
| 146 | + | for _, c := range changes { | |
| 147 | + | switch c.Type { | |
| 148 | + | case port.KeyChangeAdded: | |
| 149 | + | added++ | |
| 150 | + | case port.KeyChangeRenamed: | |
| 151 | + | renamed++ | |
| 152 | + | case port.KeyChangeRemoved: | |
| 153 | + | removed++ | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 158 | + | addStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 159 | + | renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 160 | + | removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 161 | + | ||
| 162 | + | var parts []string | |
| 163 | + | if renamed > 0 { | |
| 164 | + | parts = append(parts, renameStyle.Render(fmt.Sprintf("%d renamed", renamed))) | |
| 165 | + | } | |
| 166 | + | if added > 0 { | |
| 167 | + | parts = append(parts, addStyle.Render(fmt.Sprintf("%d new", added))) | |
| 168 | + | } | |
| 169 | + | if removed > 0 { | |
| 170 | + | parts = append(parts, removeStyle.Render(fmt.Sprintf("%d deprecated", removed))) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | if len(parts) == 0 { | |
| 174 | + | return "" | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | return fmt.Sprintf("\n %s %s", iconStyle.Render(IconInfo), strings.Join(parts, ", ")) | |
| 178 | + | } | |
| 179 | + | ||
| 72 | 180 | // RenderMigrationSuccess renders the success message after migration. | |
| 73 | 181 | func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | |
| 74 | 182 | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| @@ -79,7 +187,7 @@ func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | |||
| 79 | 187 | filename := filepath.Base(path) | |
| 80 | 188 | ||
| 81 | 189 | return fmt.Sprintf( | |
| 82 | - | "\n %s Added %s new settings to %s\n", | |
| 190 | + | "\n %s Applied %s changes to %s\n", | |
| 83 | 191 | iconStyle.Render(IconCheck), | |
| 84 | 192 | countStyle.Render(fmt.Sprintf("%d", count)), | |
| 85 | 193 | pathStyle.Render(filename), | |
| @@ -21,16 +21,16 @@ func NewConfigRenderer(theme *Theme) *ConfigRenderer { | |||||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | // RenderConfigInfo renders the config file info with status. | 23 | // RenderConfigInfo renders the config file info with status. | ||
| 24 | - | func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string { | 24 | + | func (r *ConfigRenderer) RenderConfigInfo(path string, changeCount int) string { |
| 25 | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | 25 | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | ||
| 26 | pathStyle := r.theme.Subtle | 26 | pathStyle := r.theme.Subtle | ||
| 27 | 27 | ||||
| 28 | var status string | 28 | var status string | ||
| 29 | - | if missingCount > 0 { | 29 | + | if changeCount > 0 { |
| 30 | countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | 30 | countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | ||
| 31 | - | status = fmt.Sprintf("\n %s %s new settings available", | 31 | + | status = fmt.Sprintf("\n %s %s changes detected", |
| 32 | iconStyle.Render(IconInfo), | 32 | iconStyle.Render(IconInfo), | ||
| 33 | - | countStyle.Render(fmt.Sprintf("%d", missingCount)), | 33 | + | countStyle.Render(fmt.Sprintf("%d", changeCount)), |
| 34 | ) | 34 | ) | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| @@ -69,6 +69,114 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |||||
| 69 | return sb.String() | 69 | return sb.String() | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | // RenderChanges renders the list of detected changes (added, renamed, removed). | |||
| 73 | + | func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string { | |||
| 74 | + | if len(changes) == 0 { | |||
| 75 | + | return "" | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | addStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |||
| 79 | + | renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |||
| 80 | + | removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |||
| 81 | + | keyStyle := r.theme.Highlight | |||
| 82 | + | valueStyle := r.theme.Subtle | |||
| 83 | + | ||||
| 84 | + | // Group changes by type | |||
| 85 | + | var added, renamed, removed []port.KeyChange | |||
| 86 | + | for _, c := range changes { | |||
| 87 | + | switch c.Type { | |||
| 88 | + | case port.KeyChangeAdded: | |||
| 89 | + | added = append(added, c) | |||
| 90 | + | case port.KeyChangeRenamed: | |||
| 91 | + | renamed = append(renamed, c) | |||
| 92 | + | case port.KeyChangeRemoved: | |||
| 93 | + | removed = append(removed, c) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | var sb strings.Builder | |||
| 98 | + | ||||
| 99 | + | // Show renamed keys first (most important for user to understand) | |||
| 100 | + | if len(renamed) > 0 { | |||
| 101 | + | sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed))) | |||
| 102 | + | for _, c := range renamed { | |||
| 103 | + | sb.WriteString(fmt.Sprintf( | |||
| 104 | + | " %s %s %s %s\n Value: %s\n", | |||
| 105 | + | renameStyle.Render("~"), | |||
| 106 | + | valueStyle.Render(c.OldKey), | |||
| 107 | + | renameStyle.Render("→"), | |||
| 108 | + | keyStyle.Render(c.NewKey), | |||
| 109 | + | valueStyle.Render(c.OldValue), | |||
| 110 | + | )) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Show new settings | |||
| 115 | + | if len(added) > 0 { | |||
| 116 | + | sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added))) | |||
| 117 | + | for _, c := range added { | |||
| 118 | + | sb.WriteString(fmt.Sprintf( | |||
| 119 | + | " %s %s\n Default: %s\n", | |||
| 120 | + | addStyle.Render("+"), | |||
| 121 | + | keyStyle.Render(c.NewKey), | |||
| 122 | + | valueStyle.Render(c.NewValue), | |||
| 123 | + | )) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // Show deprecated settings (will be removed) | |||
| 128 | + | if len(removed) > 0 { | |||
| 129 | + | sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed))) | |||
| 130 | + | for _, c := range removed { | |||
| 131 | + | sb.WriteString(fmt.Sprintf( | |||
| 132 | + | " %s %s %s\n", | |||
| 133 | + | removeStyle.Render("-"), | |||
| 134 | + | valueStyle.Render(c.OldKey), | |||
| 135 | + | removeStyle.Render("(will be removed)"), | |||
| 136 | + | )) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | return sb.String() | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // RenderChangesSummary renders a summary of changes for the config info header. | |||
| 144 | + | func (r *ConfigRenderer) RenderChangesSummary(changes []port.KeyChange) string { | |||
| 145 | + | var added, renamed, removed int | |||
| 146 | + | for _, c := range changes { | |||
| 147 | + | switch c.Type { | |||
| 148 | + | case port.KeyChangeAdded: | |||
| 149 | + | added++ | |||
| 150 | + | case port.KeyChangeRenamed: | |||
| 151 | + | renamed++ | |||
| 152 | + | case port.KeyChangeRemoved: | |||
| 153 | + | removed++ | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 158 | + | addStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |||
| 159 | + | renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |||
| 160 | + | removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |||
| 161 | + | ||||
| 162 | + | var parts []string | |||
| 163 | + | if renamed > 0 { | |||
| 164 | + | parts = append(parts, renameStyle.Render(fmt.Sprintf("%d renamed", renamed))) | |||
| 165 | + | } | |||
| 166 | + | if added > 0 { | |||
| 167 | + | parts = append(parts, addStyle.Render(fmt.Sprintf("%d new", added))) | |||
| 168 | + | } | |||
| 169 | + | if removed > 0 { | |||
| 170 | + | parts = append(parts, removeStyle.Render(fmt.Sprintf("%d deprecated", removed))) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | if len(parts) == 0 { | |||
| 174 | + | return "" | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | return fmt.Sprintf("\n %s %s", iconStyle.Render(IconInfo), strings.Join(parts, ", ")) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 72 | // RenderMigrationSuccess renders the success message after migration. | 180 | // RenderMigrationSuccess renders the success message after migration. | ||
| 73 | func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | 181 | func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | ||
| 74 | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | 182 | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | ||
| @@ -79,7 +187,7 @@ func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | |||||
| 79 | filename := filepath.Base(path) | 187 | filename := filepath.Base(path) | ||
| 80 | 188 | ||||
| 81 | return fmt.Sprintf( | 189 | return fmt.Sprintf( | ||
| 82 | - | "\n %s Added %s new settings to %s\n", | 190 | + | "\n %s Applied %s changes to %s\n", |
| 83 | iconStyle.Render(IconCheck), | 191 | iconStyle.Render(IconCheck), | ||
| 84 | countStyle.Render(fmt.Sprintf("%d", count)), | 192 | countStyle.Render(fmt.Sprintf("%d", count)), | ||
| 85 | pathStyle.Render(filename), | 193 | pathStyle.Render(filename), | ||
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{ | ||
A internal/infrastructure/config/diff_formatter.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | |
| 11 | + | type ConfigDiffFormatter struct{} | |
| 12 | + | ||
| 13 | + | // NewDiffFormatter creates a new ConfigDiffFormatter. | |
| 14 | + | func NewDiffFormatter() *ConfigDiffFormatter { | |
| 15 | + | return &ConfigDiffFormatter{} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // FormatChangesAsDiff returns changes formatted as a diff for display. | |
| 19 | + | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |
| 20 | + | if len(changes) == 0 { | |
| 21 | + | return "No changes detected." | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | var sb strings.Builder | |
| 25 | + | sb.WriteString("Config migration changes:\n\n") | |
| 26 | + | ||
| 27 | + | for _, change := range changes { | |
| 28 | + | switch change.Type { | |
| 29 | + | case port.KeyChangeAdded: | |
| 30 | + | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |
| 31 | + | case port.KeyChangeRemoved: | |
| 32 | + | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |
| 33 | + | case port.KeyChangeRenamed: | |
| 34 | + | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |
| 35 | + | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | return sb.String() | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | |||
| 11 | + | type ConfigDiffFormatter struct{} | |||
| 12 | + | ||||
| 13 | + | // NewDiffFormatter creates a new ConfigDiffFormatter. | |||
| 14 | + | func NewDiffFormatter() *ConfigDiffFormatter { | |||
| 15 | + | return &ConfigDiffFormatter{} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // FormatChangesAsDiff returns changes formatted as a diff for display. | |||
| 19 | + | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |||
| 20 | + | if len(changes) == 0 { | |||
| 21 | + | return "No changes detected." | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | var sb strings.Builder | |||
| 25 | + | sb.WriteString("Config migration changes:\n\n") | |||
| 26 | + | ||||
| 27 | + | for _, change := range changes { | |||
| 28 | + | switch change.Type { | |||
| 29 | + | case port.KeyChangeAdded: | |||
| 30 | + | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |||
| 31 | + | case port.KeyChangeRemoved: | |||
| 32 | + | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |||
| 33 | + | case port.KeyChangeRenamed: | |||
| 34 | + | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |||
| 35 | + | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | return sb.String() | |||
| 40 | + | } | |||
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 +338 -8
| @@ -71,20 +71,293 @@ 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, ensuring each new token is matched at most once | |
| 322 | + | matches := 0 | |
| 323 | + | matchedNew := make([]bool, len(newTokens)) | |
| 324 | + | for _, ot := range oldTokens { | |
| 325 | + | for j, nt := range newTokens { | |
| 326 | + | if ot == nt && !matchedNew[j] { | |
| 327 | + | matches++ | |
| 328 | + | matchedNew[j] = true | |
| 329 | + | break | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // If most tokens match, consider similar | |
| 335 | + | minTokens := len(oldTokens) | |
| 336 | + | if len(newTokens) < minTokens { | |
| 337 | + | minTokens = len(newTokens) | |
| 338 | + | } | |
| 339 | + | return matches >= minTokens-1 && matches > 0 | |
| 340 | + | } | |
| 341 | + | ||
| 74 | 342 | // Migrate adds missing default keys to the user's config file. | |
| 75 | 343 | func (m *Migrator) Migrate() ([]string, error) { | |
| 76 | - | result, err := m.CheckMigration() | |
| 344 | + | changes, err := m.DetectChanges() | |
| 77 | 345 | if err != nil { | |
| 78 | 346 | return nil, err | |
| 79 | 347 | } | |
| 80 | 348 | ||
| 81 | - | if result == nil || len(result.MissingKeys) == 0 { | |
| 349 | + | if len(changes) == 0 { | |
| 82 | 350 | return nil, nil | |
| 83 | 351 | } | |
| 84 | 352 | ||
| 353 | + | configFile, err := GetConfigFile() | |
| 354 | + | if err != nil { | |
| 355 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |
| 356 | + | } | |
| 357 | + | ||
| 85 | 358 | // Create a new Viper instance to read user config | |
| 86 | 359 | userViper := viper.New() | |
| 87 | - | userViper.SetConfigFile(result.ConfigFile) | |
| 360 | + | userViper.SetConfigFile(configFile) | |
| 88 | 361 | userViper.SetConfigType("toml") | |
| 89 | 362 | ||
| 90 | 363 | // Set all defaults first | |
| @@ -96,12 +369,31 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 96 | 369 | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| 97 | 370 | } | |
| 98 | 371 | ||
| 372 | + | // Apply changes | |
| 373 | + | var appliedKeys []string | |
| 374 | + | for _, change := range changes { | |
| 375 | + | switch change.Type { | |
| 376 | + | case port.KeyChangeRenamed: | |
| 377 | + | // Copy value from old key to new key | |
| 378 | + | if userViper.IsSet(change.OldKey) { | |
| 379 | + | userViper.Set(change.NewKey, userViper.Get(change.OldKey)) | |
| 380 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) | |
| 381 | + | } | |
| 382 | + | case port.KeyChangeAdded: | |
| 383 | + | // New key is already set via defaults, just track it | |
| 384 | + | appliedKeys = append(appliedKeys, change.NewKey) | |
| 385 | + | case port.KeyChangeRemoved: | |
| 386 | + | // Nothing to do - old key will be preserved but not used | |
| 387 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey)) | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 99 | 391 | // Write the merged config back | |
| 100 | 392 | if err := userViper.WriteConfig(); err != nil { | |
| 101 | 393 | return nil, fmt.Errorf("failed to write config file: %w", err) | |
| 102 | 394 | } | |
| 103 | 395 | ||
| 104 | - | return result.MissingKeys, nil | |
| 396 | + | return appliedKeys, nil | |
| 105 | 397 | } | |
| 106 | 398 | ||
| 107 | 399 | // GetKeyInfo returns detailed information about a config key. | |
| @@ -142,6 +434,20 @@ func (m *Migrator) getAllDefaultKeys() []string { | |||
| 142 | 434 | ||
| 143 | 435 | // getUserConfigKeys parses the user's TOML file and returns all defined keys. | |
| 144 | 436 | func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { | |
| 437 | + | keysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |
| 438 | + | if err != nil { | |
| 439 | + | return nil, err | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | keys := make(map[string]bool) | |
| 443 | + | for k := range keysWithValues { | |
| 444 | + | keys[k] = true | |
| 445 | + | } | |
| 446 | + | return keys, nil | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values. | |
| 450 | + | func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) { | |
| 145 | 451 | data, err := os.ReadFile(configFile) | |
| 146 | 452 | if err != nil { | |
| 147 | 453 | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| @@ -153,11 +459,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) | |||
| 153 | 459 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | |
| 154 | 460 | } | |
| 155 | 461 | ||
| 156 | - | // Flatten the map to dot-notation keys | |
| 157 | - | keys := make(map[string]bool) | |
| 158 | - | m.flattenMap(rawConfig, "", keys) | |
| 462 | + | // Flatten the map to dot-notation keys with values | |
| 463 | + | result := make(map[string]any) | |
| 464 | + | m.flattenMapWithValues(rawConfig, "", result) | |
| 159 | 465 | ||
| 160 | - | return keys, nil | |
| 466 | + | return result, nil | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values. | |
| 470 | + | func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) { | |
| 471 | + | for k, v := range data { | |
| 472 | + | key := k | |
| 473 | + | if prefix != "" { | |
| 474 | + | key = prefix + "." + k | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | switch val := v.(type) { | |
| 478 | + | case map[string]any: | |
| 479 | + | // Check if this key represents user data (like search_shortcuts, actions) | |
| 480 | + | // that should be treated as a single key rather than recursed into. | |
| 481 | + | if m.isUserDataSection(key) { | |
| 482 | + | result[key] = v | |
| 483 | + | } else { | |
| 484 | + | // Recurse into nested config sections | |
| 485 | + | m.flattenMapWithValues(val, key, result) | |
| 486 | + | } | |
| 487 | + | default: | |
| 488 | + | result[key] = v | |
| 489 | + | } | |
| 490 | + | } | |
| 161 | 491 | } | |
| 162 | 492 | ||
| 163 | 493 | // flattenMap recursively flattens a nested map to dot-notation keys. | |
| @@ -71,20 +71,293 @@ 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, ensuring each new token is matched at most once | |||
| 322 | + | matches := 0 | |||
| 323 | + | matchedNew := make([]bool, len(newTokens)) | |||
| 324 | + | for _, ot := range oldTokens { | |||
| 325 | + | for j, nt := range newTokens { | |||
| 326 | + | if ot == nt && !matchedNew[j] { | |||
| 327 | + | matches++ | |||
| 328 | + | matchedNew[j] = true | |||
| 329 | + | break | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // If most tokens match, consider similar | |||
| 335 | + | minTokens := len(oldTokens) | |||
| 336 | + | if len(newTokens) < minTokens { | |||
| 337 | + | minTokens = len(newTokens) | |||
| 338 | + | } | |||
| 339 | + | return matches >= minTokens-1 && matches > 0 | |||
| 340 | + | } | |||
| 341 | + | ||||
| 74 | // Migrate adds missing default keys to the user's config file. | 342 | // Migrate adds missing default keys to the user's config file. | ||
| 75 | func (m *Migrator) Migrate() ([]string, error) { | 343 | func (m *Migrator) Migrate() ([]string, error) { | ||
| 76 | - | result, err := m.CheckMigration() | 344 | + | changes, err := m.DetectChanges() |
| 77 | if err != nil { | 345 | if err != nil { | ||
| 78 | return nil, err | 346 | return nil, err | ||
| 79 | } | 347 | } | ||
| 80 | 348 | ||||
| 81 | - | if result == nil || len(result.MissingKeys) == 0 { | 349 | + | if len(changes) == 0 { |
| 82 | return nil, nil | 350 | return nil, nil | ||
| 83 | } | 351 | } | ||
| 84 | 352 | ||||
| 353 | + | configFile, err := GetConfigFile() | |||
| 354 | + | if err != nil { | |||
| 355 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |||
| 356 | + | } | |||
| 357 | + | ||||
| 85 | // Create a new Viper instance to read user config | 358 | // Create a new Viper instance to read user config | ||
| 86 | userViper := viper.New() | 359 | userViper := viper.New() | ||
| 87 | - | userViper.SetConfigFile(result.ConfigFile) | 360 | + | userViper.SetConfigFile(configFile) |
| 88 | userViper.SetConfigType("toml") | 361 | userViper.SetConfigType("toml") | ||
| 89 | 362 | ||||
| 90 | // Set all defaults first | 363 | // Set all defaults first | ||
| @@ -96,12 +369,31 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 96 | return nil, fmt.Errorf("failed to read config file: %w", err) | 369 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| 97 | } | 370 | } | ||
| 98 | 371 | ||||
| 372 | + | // Apply changes | |||
| 373 | + | var appliedKeys []string | |||
| 374 | + | for _, change := range changes { | |||
| 375 | + | switch change.Type { | |||
| 376 | + | case port.KeyChangeRenamed: | |||
| 377 | + | // Copy value from old key to new key | |||
| 378 | + | if userViper.IsSet(change.OldKey) { | |||
| 379 | + | userViper.Set(change.NewKey, userViper.Get(change.OldKey)) | |||
| 380 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) | |||
| 381 | + | } | |||
| 382 | + | case port.KeyChangeAdded: | |||
| 383 | + | // New key is already set via defaults, just track it | |||
| 384 | + | appliedKeys = append(appliedKeys, change.NewKey) | |||
| 385 | + | case port.KeyChangeRemoved: | |||
| 386 | + | // Nothing to do - old key will be preserved but not used | |||
| 387 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey)) | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 99 | // Write the merged config back | 391 | // Write the merged config back | ||
| 100 | if err := userViper.WriteConfig(); err != nil { | 392 | if err := userViper.WriteConfig(); err != nil { | ||
| 101 | return nil, fmt.Errorf("failed to write config file: %w", err) | 393 | return nil, fmt.Errorf("failed to write config file: %w", err) | ||
| 102 | } | 394 | } | ||
| 103 | 395 | ||||
| 104 | - | return result.MissingKeys, nil | 396 | + | return appliedKeys, nil |
| 105 | } | 397 | } | ||
| 106 | 398 | ||||
| 107 | // GetKeyInfo returns detailed information about a config key. | 399 | // GetKeyInfo returns detailed information about a config key. | ||
| @@ -142,6 +434,20 @@ func (m *Migrator) getAllDefaultKeys() []string { | |||||
| 142 | 434 | ||||
| 143 | // getUserConfigKeys parses the user's TOML file and returns all defined keys. | 435 | // getUserConfigKeys parses the user's TOML file and returns all defined keys. | ||
| 144 | func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { | 436 | func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { | ||
| 437 | + | keysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |||
| 438 | + | if err != nil { | |||
| 439 | + | return nil, err | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | keys := make(map[string]bool) | |||
| 443 | + | for k := range keysWithValues { | |||
| 444 | + | keys[k] = true | |||
| 445 | + | } | |||
| 446 | + | return keys, nil | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values. | |||
| 450 | + | func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) { | |||
| 145 | data, err := os.ReadFile(configFile) | 451 | data, err := os.ReadFile(configFile) | ||
| 146 | if err != nil { | 452 | if err != nil { | ||
| 147 | return nil, fmt.Errorf("failed to read config file: %w", err) | 453 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| @@ -153,11 +459,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) | |||||
| 153 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | 459 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | ||
| 154 | } | 460 | } | ||
| 155 | 461 | ||||
| 156 | - | // Flatten the map to dot-notation keys | 462 | + | // Flatten the map to dot-notation keys with values |
| 157 | - | keys := make(map[string]bool) | 463 | + | result := make(map[string]any) |
| 158 | - | m.flattenMap(rawConfig, "", keys) | 464 | + | m.flattenMapWithValues(rawConfig, "", result) |
| 159 | 465 | ||||
| 160 | - | return keys, nil | 466 | + | return result, nil |
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values. | |||
| 470 | + | func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) { | |||
| 471 | + | for k, v := range data { | |||
| 472 | + | key := k | |||
| 473 | + | if prefix != "" { | |||
| 474 | + | key = prefix + "." + k | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | switch val := v.(type) { | |||
| 478 | + | case map[string]any: | |||
| 479 | + | // Check if this key represents user data (like search_shortcuts, actions) | |||
| 480 | + | // that should be treated as a single key rather than recursed into. | |||
| 481 | + | if m.isUserDataSection(key) { | |||
| 482 | + | result[key] = v | |||
| 483 | + | } else { | |||
| 484 | + | // Recurse into nested config sections | |||
| 485 | + | m.flattenMapWithValues(val, key, result) | |||
| 486 | + | } | |||
| 487 | + | default: | |||
| 488 | + | result[key] = v | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 161 | } | 491 | } | ||
| 162 | 492 | ||||
| 163 | // flattenMap recursively flattens a nested map to dot-notation keys. | 493 | // flattenMap recursively flattens a nested map to dot-notation keys. | ||
M internal/infrastructure/config/migrate_test.go +277 -0
| @@ -340,6 +340,283 @@ func TestMigrator_FormatValue(t *testing.T) { | |||
| 340 | 340 | } | |
| 341 | 341 | } | |
| 342 | 342 | ||
| 343 | + | func TestKeysAreSimilar(t *testing.T) { | |
| 344 | + | tests := []struct { | |
| 345 | + | name string | |
| 346 | + | oldKey string | |
| 347 | + | newKey string | |
| 348 | + | expected bool | |
| 349 | + | }{ | |
| 350 | + | { | |
| 351 | + | name: "identical keys", | |
| 352 | + | oldKey: "section.pane_mode_color", | |
| 353 | + | newKey: "section.pane_mode_color", | |
| 354 | + | expected: true, | |
| 355 | + | }, | |
| 356 | + | { | |
| 357 | + | name: "substring match - old contains new", | |
| 358 | + | oldKey: "section.pane_mode_border_color", | |
| 359 | + | newKey: "section.pane_mode_color", | |
| 360 | + | expected: true, | |
| 361 | + | }, | |
| 362 | + | { | |
| 363 | + | name: "substring match - new contains old", | |
| 364 | + | oldKey: "section.mode_color", | |
| 365 | + | newKey: "section.pane_mode_color", | |
| 366 | + | expected: true, | |
| 367 | + | }, | |
| 368 | + | { | |
| 369 | + | name: "token matching - border removed", | |
| 370 | + | oldKey: "section.tab_mode_border_color", | |
| 371 | + | newKey: "section.tab_mode_color", | |
| 372 | + | expected: true, | |
| 373 | + | }, | |
| 374 | + | { | |
| 375 | + | name: "different parent paths", | |
| 376 | + | oldKey: "section_a.pane_mode_color", | |
| 377 | + | newKey: "section_b.pane_mode_color", | |
| 378 | + | expected: false, | |
| 379 | + | }, | |
| 380 | + | { | |
| 381 | + | name: "different depth", | |
| 382 | + | oldKey: "pane_mode_color", | |
| 383 | + | newKey: "section.pane_mode_color", | |
| 384 | + | expected: false, | |
| 385 | + | }, | |
| 386 | + | { | |
| 387 | + | name: "completely different keys", | |
| 388 | + | oldKey: "section.foo_bar", | |
| 389 | + | newKey: "section.baz_qux", | |
| 390 | + | expected: false, | |
| 391 | + | }, | |
| 392 | + | { | |
| 393 | + | name: "top level keys rejected", | |
| 394 | + | oldKey: "foo", | |
| 395 | + | newKey: "bar", | |
| 396 | + | expected: false, | |
| 397 | + | }, | |
| 398 | + | { | |
| 399 | + | name: "no common tokens", | |
| 400 | + | oldKey: "section.aaa_bbb", | |
| 401 | + | newKey: "section.ccc_ddd", | |
| 402 | + | expected: false, | |
| 403 | + | }, | |
| 404 | + | { | |
| 405 | + | name: "single token difference allowed", | |
| 406 | + | oldKey: "section.pane_mode_border_width", | |
| 407 | + | newKey: "section.mode_border_width", | |
| 408 | + | expected: true, | |
| 409 | + | }, | |
| 410 | + | { | |
| 411 | + | name: "duplicate tokens in old key - no double counting", | |
| 412 | + | oldKey: "section.mode_mode_mode", | |
| 413 | + | newKey: "section.mode_other_another", | |
| 414 | + | expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false | |
| 415 | + | }, | |
| 416 | + | { | |
| 417 | + | name: "enough common tokens with duplicates", | |
| 418 | + | oldKey: "section.pane_mode_border", | |
| 419 | + | newKey: "section.pane_mode_color", | |
| 420 | + | expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true | |
| 421 | + | }, | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | for _, tt := range tests { | |
| 425 | + | t.Run(tt.name, func(t *testing.T) { | |
| 426 | + | result := keysAreSimilar(tt.oldKey, tt.newKey) | |
| 427 | + | assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey) | |
| 428 | + | }) | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func TestMigrator_TypesAreCompatible(t *testing.T) { | |
| 433 | + | m := NewMigrator() | |
| 434 | + | ||
| 435 | + | tests := []struct { | |
| 436 | + | name string | |
| 437 | + | oldKey string | |
| 438 | + | newKey string | |
| 439 | + | expected bool | |
| 440 | + | }{ | |
| 441 | + | { | |
| 442 | + | name: "color to color - compatible (using real key)", | |
| 443 | + | oldKey: "workspace.styling.pane_mode_border_color", | |
| 444 | + | newKey: "workspace.styling.pane_mode_color", | |
| 445 | + | expected: true, | |
| 446 | + | }, | |
| 447 | + | { | |
| 448 | + | name: "width to width - compatible (using real key)", | |
| 449 | + | oldKey: "workspace.styling.pane_mode_border_width", | |
| 450 | + | newKey: "workspace.styling.mode_border_width", | |
| 451 | + | expected: true, | |
| 452 | + | }, | |
| 453 | + | { | |
| 454 | + | name: "width suffix to color type - incompatible", | |
| 455 | + | oldKey: "workspace.styling.some_width", | |
| 456 | + | newKey: "workspace.styling.pane_mode_color", // real key with string type | |
| 457 | + | expected: false, | |
| 458 | + | }, | |
| 459 | + | { | |
| 460 | + | name: "color suffix to int type - incompatible", | |
| 461 | + | oldKey: "workspace.styling.some_color", | |
| 462 | + | newKey: "workspace.styling.mode_border_width", // real key with int type | |
| 463 | + | expected: false, | |
| 464 | + | }, | |
| 465 | + | { | |
| 466 | + | name: "generic key to string type - compatible", | |
| 467 | + | oldKey: "workspace.styling.some_setting", | |
| 468 | + | newKey: "workspace.styling.pane_mode_color", // real key with string type | |
| 469 | + | expected: true, | |
| 470 | + | }, | |
| 471 | + | { | |
| 472 | + | name: "non-existent new key - incompatible", | |
| 473 | + | oldKey: "section.old_key", | |
| 474 | + | newKey: "section.nonexistent_key_xyz", | |
| 475 | + | expected: false, | |
| 476 | + | }, | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | for _, tt := range tests { | |
| 480 | + | t.Run(tt.name, func(t *testing.T) { | |
| 481 | + | result := m.typesAreCompatible(tt.oldKey, tt.newKey) | |
| 482 | + | assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey) | |
| 483 | + | }) | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func TestMigrator_FindDeprecatedKeys(t *testing.T) { | |
| 488 | + | m := NewMigrator() | |
| 489 | + | ||
| 490 | + | tests := []struct { | |
| 491 | + | name string | |
| 492 | + | userKeys map[string]any | |
| 493 | + | defaultKeys map[string]bool | |
| 494 | + | expected []string | |
| 495 | + | }{ | |
| 496 | + | { | |
| 497 | + | name: "no deprecated keys", | |
| 498 | + | userKeys: map[string]any{ | |
| 499 | + | "history.max_entries": 1000, | |
| 500 | + | "logging.level": "info", | |
| 501 | + | }, | |
| 502 | + | defaultKeys: map[string]bool{ | |
| 503 | + | "history.max_entries": true, | |
| 504 | + | "logging.level": true, | |
| 505 | + | }, | |
| 506 | + | expected: []string{}, | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | name: "one deprecated key", | |
| 510 | + | userKeys: map[string]any{ | |
| 511 | + | "history.max_entries": 1000, | |
| 512 | + | "old.deprecated_key": "value", | |
| 513 | + | }, | |
| 514 | + | defaultKeys: map[string]bool{ | |
| 515 | + | "history.max_entries": true, | |
| 516 | + | }, | |
| 517 | + | expected: []string{"old.deprecated_key"}, | |
| 518 | + | }, | |
| 519 | + | { | |
| 520 | + | name: "multiple deprecated keys", | |
| 521 | + | userKeys: map[string]any{ | |
| 522 | + | "old.key1": "a", | |
| 523 | + | "old.key2": "b", | |
| 524 | + | "old.key3": "c", | |
| 525 | + | }, | |
| 526 | + | defaultKeys: map[string]bool{ | |
| 527 | + | "new.key1": true, | |
| 528 | + | }, | |
| 529 | + | expected: []string{"old.key1", "old.key2", "old.key3"}, | |
| 530 | + | }, | |
| 531 | + | { | |
| 532 | + | name: "parent key exists in defaults", | |
| 533 | + | userKeys: map[string]any{ | |
| 534 | + | "parent.child": "value", | |
| 535 | + | }, | |
| 536 | + | defaultKeys: map[string]bool{ | |
| 537 | + | "parent.child": true, | |
| 538 | + | "parent.other_child": true, | |
| 539 | + | }, | |
| 540 | + | expected: []string{}, | |
| 541 | + | }, | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | for _, tt := range tests { | |
| 545 | + | t.Run(tt.name, func(t *testing.T) { | |
| 546 | + | result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys) | |
| 547 | + | assert.ElementsMatch(t, tt.expected, result) | |
| 548 | + | }) | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func TestMigrator_MatchRenamedKeys(t *testing.T) { | |
| 553 | + | m := NewMigrator() | |
| 554 | + | ||
| 555 | + | tests := []struct { | |
| 556 | + | name string | |
| 557 | + | deprecated []string | |
| 558 | + | missing []string | |
| 559 | + | expectedRenames map[string]string | |
| 560 | + | expectedUnmatchedOld []string | |
| 561 | + | expectedUnmatchedNew []string | |
| 562 | + | }{ | |
| 563 | + | { | |
| 564 | + | name: "no keys to match", | |
| 565 | + | deprecated: []string{}, | |
| 566 | + | missing: []string{}, | |
| 567 | + | expectedRenames: map[string]string{}, | |
| 568 | + | expectedUnmatchedOld: nil, | |
| 569 | + | expectedUnmatchedNew: nil, | |
| 570 | + | }, | |
| 571 | + | { | |
| 572 | + | name: "simple rename match (using real keys)", | |
| 573 | + | deprecated: []string{"workspace.styling.pane_mode_border_color"}, | |
| 574 | + | missing: []string{"workspace.styling.pane_mode_color"}, | |
| 575 | + | expectedRenames: map[string]string{ | |
| 576 | + | "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color", | |
| 577 | + | }, | |
| 578 | + | expectedUnmatchedOld: nil, | |
| 579 | + | expectedUnmatchedNew: nil, | |
| 580 | + | }, | |
| 581 | + | { | |
| 582 | + | name: "no match possible - different types (width vs color)", | |
| 583 | + | deprecated: []string{"workspace.styling.pane_mode_border_width"}, | |
| 584 | + | missing: []string{"workspace.styling.pane_mode_color"}, | |
| 585 | + | expectedRenames: map[string]string{}, | |
| 586 | + | expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"}, | |
| 587 | + | expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"}, | |
| 588 | + | }, | |
| 589 | + | { | |
| 590 | + | name: "no match possible - different parents", | |
| 591 | + | deprecated: []string{"old_section.some_key"}, | |
| 592 | + | missing: []string{"new_section.some_key"}, | |
| 593 | + | expectedRenames: map[string]string{}, | |
| 594 | + | expectedUnmatchedOld: []string{"old_section.some_key"}, | |
| 595 | + | expectedUnmatchedNew: []string{"new_section.some_key"}, | |
| 596 | + | }, | |
| 597 | + | { | |
| 598 | + | name: "multiple renames (using real keys)", | |
| 599 | + | deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"}, | |
| 600 | + | missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"}, | |
| 601 | + | expectedRenames: map[string]string{ | |
| 602 | + | "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color", | |
| 603 | + | "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color", | |
| 604 | + | }, | |
| 605 | + | expectedUnmatchedOld: nil, | |
| 606 | + | expectedUnmatchedNew: nil, | |
| 607 | + | }, | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | for _, tt := range tests { | |
| 611 | + | t.Run(tt.name, func(t *testing.T) { | |
| 612 | + | renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing) | |
| 613 | + | assert.Equal(t, tt.expectedRenames, renames, "renames mismatch") | |
| 614 | + | assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch") | |
| 615 | + | assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch") | |
| 616 | + | }) | |
| 617 | + | } | |
| 618 | + | } | |
| 619 | + | ||
| 343 | 620 | func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) { | |
| 344 | 621 | // This test would need to temporarily modify the config path | |
| 345 | 622 | // which is difficult without refactoring. Skip for now. | |
| @@ -340,6 +340,283 @@ func TestMigrator_FormatValue(t *testing.T) { | |||||
| 340 | } | 340 | } | ||
| 341 | } | 341 | } | ||
| 342 | 342 | ||||
| 343 | + | func TestKeysAreSimilar(t *testing.T) { | |||
| 344 | + | tests := []struct { | |||
| 345 | + | name string | |||
| 346 | + | oldKey string | |||
| 347 | + | newKey string | |||
| 348 | + | expected bool | |||
| 349 | + | }{ | |||
| 350 | + | { | |||
| 351 | + | name: "identical keys", | |||
| 352 | + | oldKey: "section.pane_mode_color", | |||
| 353 | + | newKey: "section.pane_mode_color", | |||
| 354 | + | expected: true, | |||
| 355 | + | }, | |||
| 356 | + | { | |||
| 357 | + | name: "substring match - old contains new", | |||
| 358 | + | oldKey: "section.pane_mode_border_color", | |||
| 359 | + | newKey: "section.pane_mode_color", | |||
| 360 | + | expected: true, | |||
| 361 | + | }, | |||
| 362 | + | { | |||
| 363 | + | name: "substring match - new contains old", | |||
| 364 | + | oldKey: "section.mode_color", | |||
| 365 | + | newKey: "section.pane_mode_color", | |||
| 366 | + | expected: true, | |||
| 367 | + | }, | |||
| 368 | + | { | |||
| 369 | + | name: "token matching - border removed", | |||
| 370 | + | oldKey: "section.tab_mode_border_color", | |||
| 371 | + | newKey: "section.tab_mode_color", | |||
| 372 | + | expected: true, | |||
| 373 | + | }, | |||
| 374 | + | { | |||
| 375 | + | name: "different parent paths", | |||
| 376 | + | oldKey: "section_a.pane_mode_color", | |||
| 377 | + | newKey: "section_b.pane_mode_color", | |||
| 378 | + | expected: false, | |||
| 379 | + | }, | |||
| 380 | + | { | |||
| 381 | + | name: "different depth", | |||
| 382 | + | oldKey: "pane_mode_color", | |||
| 383 | + | newKey: "section.pane_mode_color", | |||
| 384 | + | expected: false, | |||
| 385 | + | }, | |||
| 386 | + | { | |||
| 387 | + | name: "completely different keys", | |||
| 388 | + | oldKey: "section.foo_bar", | |||
| 389 | + | newKey: "section.baz_qux", | |||
| 390 | + | expected: false, | |||
| 391 | + | }, | |||
| 392 | + | { | |||
| 393 | + | name: "top level keys rejected", | |||
| 394 | + | oldKey: "foo", | |||
| 395 | + | newKey: "bar", | |||
| 396 | + | expected: false, | |||
| 397 | + | }, | |||
| 398 | + | { | |||
| 399 | + | name: "no common tokens", | |||
| 400 | + | oldKey: "section.aaa_bbb", | |||
| 401 | + | newKey: "section.ccc_ddd", | |||
| 402 | + | expected: false, | |||
| 403 | + | }, | |||
| 404 | + | { | |||
| 405 | + | name: "single token difference allowed", | |||
| 406 | + | oldKey: "section.pane_mode_border_width", | |||
| 407 | + | newKey: "section.mode_border_width", | |||
| 408 | + | expected: true, | |||
| 409 | + | }, | |||
| 410 | + | { | |||
| 411 | + | name: "duplicate tokens in old key - no double counting", | |||
| 412 | + | oldKey: "section.mode_mode_mode", | |||
| 413 | + | newKey: "section.mode_other_another", | |||
| 414 | + | expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false | |||
| 415 | + | }, | |||
| 416 | + | { | |||
| 417 | + | name: "enough common tokens with duplicates", | |||
| 418 | + | oldKey: "section.pane_mode_border", | |||
| 419 | + | newKey: "section.pane_mode_color", | |||
| 420 | + | expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true | |||
| 421 | + | }, | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | for _, tt := range tests { | |||
| 425 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 426 | + | result := keysAreSimilar(tt.oldKey, tt.newKey) | |||
| 427 | + | assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey) | |||
| 428 | + | }) | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func TestMigrator_TypesAreCompatible(t *testing.T) { | |||
| 433 | + | m := NewMigrator() | |||
| 434 | + | ||||
| 435 | + | tests := []struct { | |||
| 436 | + | name string | |||
| 437 | + | oldKey string | |||
| 438 | + | newKey string | |||
| 439 | + | expected bool | |||
| 440 | + | }{ | |||
| 441 | + | { | |||
| 442 | + | name: "color to color - compatible (using real key)", | |||
| 443 | + | oldKey: "workspace.styling.pane_mode_border_color", | |||
| 444 | + | newKey: "workspace.styling.pane_mode_color", | |||
| 445 | + | expected: true, | |||
| 446 | + | }, | |||
| 447 | + | { | |||
| 448 | + | name: "width to width - compatible (using real key)", | |||
| 449 | + | oldKey: "workspace.styling.pane_mode_border_width", | |||
| 450 | + | newKey: "workspace.styling.mode_border_width", | |||
| 451 | + | expected: true, | |||
| 452 | + | }, | |||
| 453 | + | { | |||
| 454 | + | name: "width suffix to color type - incompatible", | |||
| 455 | + | oldKey: "workspace.styling.some_width", | |||
| 456 | + | newKey: "workspace.styling.pane_mode_color", // real key with string type | |||
| 457 | + | expected: false, | |||
| 458 | + | }, | |||
| 459 | + | { | |||
| 460 | + | name: "color suffix to int type - incompatible", | |||
| 461 | + | oldKey: "workspace.styling.some_color", | |||
| 462 | + | newKey: "workspace.styling.mode_border_width", // real key with int type | |||
| 463 | + | expected: false, | |||
| 464 | + | }, | |||
| 465 | + | { | |||
| 466 | + | name: "generic key to string type - compatible", | |||
| 467 | + | oldKey: "workspace.styling.some_setting", | |||
| 468 | + | newKey: "workspace.styling.pane_mode_color", // real key with string type | |||
| 469 | + | expected: true, | |||
| 470 | + | }, | |||
| 471 | + | { | |||
| 472 | + | name: "non-existent new key - incompatible", | |||
| 473 | + | oldKey: "section.old_key", | |||
| 474 | + | newKey: "section.nonexistent_key_xyz", | |||
| 475 | + | expected: false, | |||
| 476 | + | }, | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | for _, tt := range tests { | |||
| 480 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 481 | + | result := m.typesAreCompatible(tt.oldKey, tt.newKey) | |||
| 482 | + | assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey) | |||
| 483 | + | }) | |||
| 484 | + | } | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func TestMigrator_FindDeprecatedKeys(t *testing.T) { | |||
| 488 | + | m := NewMigrator() | |||
| 489 | + | ||||
| 490 | + | tests := []struct { | |||
| 491 | + | name string | |||
| 492 | + | userKeys map[string]any | |||
| 493 | + | defaultKeys map[string]bool | |||
| 494 | + | expected []string | |||
| 495 | + | }{ | |||
| 496 | + | { | |||
| 497 | + | name: "no deprecated keys", | |||
| 498 | + | userKeys: map[string]any{ | |||
| 499 | + | "history.max_entries": 1000, | |||
| 500 | + | "logging.level": "info", | |||
| 501 | + | }, | |||
| 502 | + | defaultKeys: map[string]bool{ | |||
| 503 | + | "history.max_entries": true, | |||
| 504 | + | "logging.level": true, | |||
| 505 | + | }, | |||
| 506 | + | expected: []string{}, | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | name: "one deprecated key", | |||
| 510 | + | userKeys: map[string]any{ | |||
| 511 | + | "history.max_entries": 1000, | |||
| 512 | + | "old.deprecated_key": "value", | |||
| 513 | + | }, | |||
| 514 | + | defaultKeys: map[string]bool{ | |||
| 515 | + | "history.max_entries": true, | |||
| 516 | + | }, | |||
| 517 | + | expected: []string{"old.deprecated_key"}, | |||
| 518 | + | }, | |||
| 519 | + | { | |||
| 520 | + | name: "multiple deprecated keys", | |||
| 521 | + | userKeys: map[string]any{ | |||
| 522 | + | "old.key1": "a", | |||
| 523 | + | "old.key2": "b", | |||
| 524 | + | "old.key3": "c", | |||
| 525 | + | }, | |||
| 526 | + | defaultKeys: map[string]bool{ | |||
| 527 | + | "new.key1": true, | |||
| 528 | + | }, | |||
| 529 | + | expected: []string{"old.key1", "old.key2", "old.key3"}, | |||
| 530 | + | }, | |||
| 531 | + | { | |||
| 532 | + | name: "parent key exists in defaults", | |||
| 533 | + | userKeys: map[string]any{ | |||
| 534 | + | "parent.child": "value", | |||
| 535 | + | }, | |||
| 536 | + | defaultKeys: map[string]bool{ | |||
| 537 | + | "parent.child": true, | |||
| 538 | + | "parent.other_child": true, | |||
| 539 | + | }, | |||
| 540 | + | expected: []string{}, | |||
| 541 | + | }, | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | for _, tt := range tests { | |||
| 545 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 546 | + | result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys) | |||
| 547 | + | assert.ElementsMatch(t, tt.expected, result) | |||
| 548 | + | }) | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func TestMigrator_MatchRenamedKeys(t *testing.T) { | |||
| 553 | + | m := NewMigrator() | |||
| 554 | + | ||||
| 555 | + | tests := []struct { | |||
| 556 | + | name string | |||
| 557 | + | deprecated []string | |||
| 558 | + | missing []string | |||
| 559 | + | expectedRenames map[string]string | |||
| 560 | + | expectedUnmatchedOld []string | |||
| 561 | + | expectedUnmatchedNew []string | |||
| 562 | + | }{ | |||
| 563 | + | { | |||
| 564 | + | name: "no keys to match", | |||
| 565 | + | deprecated: []string{}, | |||
| 566 | + | missing: []string{}, | |||
| 567 | + | expectedRenames: map[string]string{}, | |||
| 568 | + | expectedUnmatchedOld: nil, | |||
| 569 | + | expectedUnmatchedNew: nil, | |||
| 570 | + | }, | |||
| 571 | + | { | |||
| 572 | + | name: "simple rename match (using real keys)", | |||
| 573 | + | deprecated: []string{"workspace.styling.pane_mode_border_color"}, | |||
| 574 | + | missing: []string{"workspace.styling.pane_mode_color"}, | |||
| 575 | + | expectedRenames: map[string]string{ | |||
| 576 | + | "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color", | |||
| 577 | + | }, | |||
| 578 | + | expectedUnmatchedOld: nil, | |||
| 579 | + | expectedUnmatchedNew: nil, | |||
| 580 | + | }, | |||
| 581 | + | { | |||
| 582 | + | name: "no match possible - different types (width vs color)", | |||
| 583 | + | deprecated: []string{"workspace.styling.pane_mode_border_width"}, | |||
| 584 | + | missing: []string{"workspace.styling.pane_mode_color"}, | |||
| 585 | + | expectedRenames: map[string]string{}, | |||
| 586 | + | expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"}, | |||
| 587 | + | expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"}, | |||
| 588 | + | }, | |||
| 589 | + | { | |||
| 590 | + | name: "no match possible - different parents", | |||
| 591 | + | deprecated: []string{"old_section.some_key"}, | |||
| 592 | + | missing: []string{"new_section.some_key"}, | |||
| 593 | + | expectedRenames: map[string]string{}, | |||
| 594 | + | expectedUnmatchedOld: []string{"old_section.some_key"}, | |||
| 595 | + | expectedUnmatchedNew: []string{"new_section.some_key"}, | |||
| 596 | + | }, | |||
| 597 | + | { | |||
| 598 | + | name: "multiple renames (using real keys)", | |||
| 599 | + | deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"}, | |||
| 600 | + | missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"}, | |||
| 601 | + | expectedRenames: map[string]string{ | |||
| 602 | + | "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color", | |||
| 603 | + | "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color", | |||
| 604 | + | }, | |||
| 605 | + | expectedUnmatchedOld: nil, | |||
| 606 | + | expectedUnmatchedNew: nil, | |||
| 607 | + | }, | |||
| 608 | + | } | |||
| 609 | + | ||||
| 610 | + | for _, tt := range tests { | |||
| 611 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 612 | + | renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing) | |||
| 613 | + | assert.Equal(t, tt.expectedRenames, renames, "renames mismatch") | |||
| 614 | + | assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch") | |||
| 615 | + | assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch") | |||
| 616 | + | }) | |||
| 617 | + | } | |||
| 618 | + | } | |||
| 619 | + | ||||
| 343 | func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) { | 620 | func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) { | ||
| 344 | // This test would need to temporarily modify the config path | 621 | // This test would need to temporarily modify the config path | ||
| 345 | // which is difficult without refactoring. Skip for now. | 622 | // which is difficult without refactoring. Skip for now. | ||
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 +60 -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,52 @@ 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 | + | // Mode class is applied atomically with Show() to avoid visual flicker. | |
| 1311 | + | modeClass := getModeToastClass(mode) | |
| 1312 | + | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 1313 | + | component.WithDuration(0), // Persistent until mode exits. | |
| 1314 | + | component.WithPosition(component.ToastPositionBottomLeft), | |
| 1315 | + | component.WithModeClass(modeClass), | |
| 1316 | + | ) | |
| 1317 | + | } | |
| 1318 | + | ||
| 1319 | + | // getModeToastClass returns the CSS class for the given mode's toast styling. | |
| 1320 | + | func getModeToastClass(mode input.Mode) string { | |
| 1321 | + | switch mode { | |
| 1322 | + | case input.ModePane: | |
| 1323 | + | return "toast-pane-mode" | |
| 1324 | + | case input.ModeTab: | |
| 1325 | + | return "toast-tab-mode" | |
| 1326 | + | case input.ModeSession: | |
| 1327 | + | return "toast-session-mode" | |
| 1328 | + | case input.ModeResize: | |
| 1329 | + | return "toast-resize-mode" | |
| 1330 | + | default: | |
| 1331 | + | return "" | |
| 1332 | + | } | |
| 1273 | 1333 | } | |
| 1274 | 1334 | ||
| 1275 | 1335 | 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,52 @@ 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 | + | // Mode class is applied atomically with Show() to avoid visual flicker. | |||
| 1311 | + | modeClass := getModeToastClass(mode) | |||
| 1312 | + | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |||
| 1313 | + | component.WithDuration(0), // Persistent until mode exits. | |||
| 1314 | + | component.WithPosition(component.ToastPositionBottomLeft), | |||
| 1315 | + | component.WithModeClass(modeClass), | |||
| 1316 | + | ) | |||
| 1317 | + | } | |||
| 1318 | + | ||||
| 1319 | + | // getModeToastClass returns the CSS class for the given mode's toast styling. | |||
| 1320 | + | func getModeToastClass(mode input.Mode) string { | |||
| 1321 | + | switch mode { | |||
| 1322 | + | case input.ModePane: | |||
| 1323 | + | return "toast-pane-mode" | |||
| 1324 | + | case input.ModeTab: | |||
| 1325 | + | return "toast-tab-mode" | |||
| 1326 | + | case input.ModeSession: | |||
| 1327 | + | return "toast-session-mode" | |||
| 1328 | + | case input.ModeResize: | |||
| 1329 | + | return "toast-resize-mode" | |||
| 1330 | + | default: | |||
| 1331 | + | return "" | |||
| 1332 | + | } | |||
| 1273 | } | 1333 | } | ||
| 1274 | 1334 | ||||
| 1275 | func (a *App) clearResizeModeBorder() { | 1335 | func (a *App) clearResizeModeBorder() { | ||
M internal/ui/component/toaster.go +239 -8
| @@ -48,6 +48,89 @@ 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 | + | // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode"). | |
| 82 | + | // When set, this class is applied atomically with Show() to avoid visual flicker. | |
| 83 | + | ModeClass string | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // ToastOption is a functional option for configuring toast display. | |
| 87 | + | type ToastOption func(*ToastOptions) | |
| 88 | + | ||
| 89 | + | // WithDuration sets the auto-dismiss duration in milliseconds. | |
| 90 | + | // Use 0 for persistent toasts that require manual dismissal. | |
| 91 | + | func WithDuration(ms int) ToastOption { | |
| 92 | + | return func(o *ToastOptions) { | |
| 93 | + | o.Duration = ms | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // WithBackgroundColor sets a custom background color (CSS color value). | |
| 98 | + | func WithBackgroundColor(color string) ToastOption { | |
| 99 | + | return func(o *ToastOptions) { | |
| 100 | + | o.BackgroundColor = color | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // WithTextColor sets a custom text color (CSS color value). | |
| 105 | + | func WithTextColor(color string) ToastOption { | |
| 106 | + | return func(o *ToastOptions) { | |
| 107 | + | o.TextColor = color | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // WithPosition sets the toast position on screen. | |
| 112 | + | func WithPosition(pos ToastPosition) ToastOption { | |
| 113 | + | return func(o *ToastOptions) { | |
| 114 | + | o.Position = pos | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // WithModeClass sets a mode-specific CSS class for styling. | |
| 119 | + | // This is applied atomically within Show() to avoid visual flicker. | |
| 120 | + | func WithModeClass(class string) ToastOption { | |
| 121 | + | return func(o *ToastOptions) { | |
| 122 | + | o.ModeClass = class | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // defaultToastOptions returns the default toast options. | |
| 127 | + | func defaultToastOptions() ToastOptions { | |
| 128 | + | return ToastOptions{ | |
| 129 | + | Duration: toastDismissTimeoutMs, | |
| 130 | + | Position: ToastPositionTopLeft, | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 51 | 134 | // Toaster displays toast notifications in an overlay. | |
| 52 | 135 | // It supports different notification levels and auto-dismissal. | |
| 53 | 136 | // When a new toast is shown while one is already visible, the text | |
| @@ -60,18 +143,26 @@ type Toaster struct { | |||
| 60 | 143 | visible bool | |
| 61 | 144 | dismissTimer uint // GLib timer source ID | |
| 62 | 145 | ||
| 146 | + | // Track current options for cleanup | |
| 147 | + | currentOpts ToastOptions | |
| 148 | + | hasCustomStyle bool | |
| 149 | + | customBgColor string // Current custom background color | |
| 150 | + | customTextColor string // Current custom text color | |
| 151 | + | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") | |
| 152 | + | customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors | |
| 153 | + | ||
| 63 | 154 | mu sync.Mutex | |
| 64 | 155 | } | |
| 65 | 156 | ||
| 66 | 157 | // NewToaster creates a new toaster component for overlay display. | |
| 67 | - | // The toaster is positioned in the top-left corner with margin. | |
| 158 | + | // The toaster is positioned in the top-left corner with margin by default. | |
| 68 | 159 | func NewToaster(factory layout.WidgetFactory) *Toaster { | |
| 69 | 160 | // Create container box for styling and positioning | |
| 70 | 161 | container := factory.NewBox(layout.OrientationHorizontal, 0) | |
| 71 | 162 | container.AddCssClass("toast") | |
| 72 | 163 | container.AddCssClass("toast-info") // Default level | |
| 73 | 164 | ||
| 74 | - | // Position in top-left with margin | |
| 165 | + | // Position in top-left with margin (default) | |
| 75 | 166 | container.SetHalign(gtk.AlignStartValue) | |
| 76 | 167 | container.SetValign(gtk.AlignStartValue) | |
| 77 | 168 | ||
| @@ -98,17 +189,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster { | |||
| 98 | 189 | label: label, | |
| 99 | 190 | currentLevel: ToastInfo, | |
| 100 | 191 | visible: false, | |
| 192 | + | currentOpts: defaultToastOptions(), | |
| 101 | 193 | } | |
| 102 | 194 | } | |
| 103 | 195 | ||
| 104 | 196 | // Show displays a toast notification with the given message and level. | |
| 105 | 197 | // 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) { | |
| 198 | + | // Optional ToastOption arguments can customize duration, colors, and position. | |
| 199 | + | func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) { | |
| 107 | 200 | log := logging.FromContext(ctx) | |
| 108 | 201 | ||
| 202 | + | // Build options from defaults and overrides | |
| 203 | + | options := defaultToastOptions() | |
| 204 | + | for _, opt := range opts { | |
| 205 | + | opt(&options) | |
| 206 | + | } | |
| 207 | + | ||
| 109 | 208 | t.mu.Lock() | |
| 110 | 209 | defer t.mu.Unlock() | |
| 111 | 210 | ||
| 211 | + | // Clear previous custom styles if any | |
| 212 | + | t.clearCustomStyle() | |
| 213 | + | ||
| 112 | 214 | // Update level CSS class if changed | |
| 113 | 215 | if t.currentLevel != level { | |
| 114 | 216 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| @@ -116,6 +218,17 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||
| 116 | 218 | t.currentLevel = level | |
| 117 | 219 | } | |
| 118 | 220 | ||
| 221 | + | // Apply custom colors if specified | |
| 222 | + | t.applyCustomStyle(options) | |
| 223 | + | ||
| 224 | + | // Apply mode class if specified (atomically to avoid flicker) | |
| 225 | + | if options.ModeClass != "" { | |
| 226 | + | t.applyModeClassInternal(options.ModeClass) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // Apply position | |
| 230 | + | t.applyPosition(options.Position) | |
| 231 | + | ||
| 119 | 232 | // Update message text | |
| 120 | 233 | t.label.SetText(message) | |
| 121 | 234 | ||
| @@ -131,12 +244,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||
| 131 | 244 | t.container.SetVisible(true) | |
| 132 | 245 | } | |
| 133 | 246 | ||
| 134 | - | // Start new dismiss timer | |
| 135 | - | t.startDismissTimer(ctx) | |
| 247 | + | // Start new dismiss timer (if duration > 0) | |
| 248 | + | if options.Duration > 0 { | |
| 249 | + | t.startDismissTimerWithDuration(ctx, options.Duration) | |
| 250 | + | } | |
| 251 | + | // Duration == 0 means persistent, no timer | |
| 252 | + | ||
| 253 | + | t.currentOpts = options | |
| 136 | 254 | ||
| 137 | 255 | log.Debug(). | |
| 138 | 256 | Str("toast_message", message). | |
| 139 | 257 | Int("toast_level", int(level)). | |
| 258 | + | Int("duration", options.Duration). | |
| 140 | 259 | Msg("toast shown") | |
| 141 | 260 | } | |
| 142 | 261 | ||
| @@ -167,13 +286,125 @@ func (t *Toaster) hide() { | |||
| 167 | 286 | t.dismissTimer = 0 | |
| 168 | 287 | } | |
| 169 | 288 | ||
| 289 | + | // Clear custom styles | |
| 290 | + | t.clearCustomStyle() | |
| 291 | + | ||
| 170 | 292 | t.visible = false | |
| 171 | 293 | t.container.SetVisible(false) | |
| 172 | 294 | } | |
| 173 | 295 | ||
| 174 | - | // startDismissTimer starts the auto-dismiss timer. | |
| 296 | + | // applyPosition sets the widget alignment based on the position. | |
| 297 | + | func (t *Toaster) applyPosition(pos ToastPosition) { | |
| 298 | + | var halign, valign gtk.Align | |
| 299 | + | ||
| 300 | + | switch pos { | |
| 301 | + | case ToastPositionTopLeft: | |
| 302 | + | halign = gtk.AlignStartValue | |
| 303 | + | valign = gtk.AlignStartValue | |
| 304 | + | case ToastPositionTopCenter: | |
| 305 | + | halign = gtk.AlignCenterValue | |
| 306 | + | valign = gtk.AlignStartValue | |
| 307 | + | case ToastPositionTopRight: | |
| 308 | + | halign = gtk.AlignEndValue | |
| 309 | + | valign = gtk.AlignStartValue | |
| 310 | + | case ToastPositionBottomLeft: | |
| 311 | + | halign = gtk.AlignStartValue | |
| 312 | + | valign = gtk.AlignEndValue | |
| 313 | + | case ToastPositionBottomCenter: | |
| 314 | + | halign = gtk.AlignCenterValue | |
| 315 | + | valign = gtk.AlignEndValue | |
| 316 | + | case ToastPositionBottomRight: | |
| 317 | + | halign = gtk.AlignEndValue | |
| 318 | + | valign = gtk.AlignEndValue | |
| 319 | + | default: | |
| 320 | + | halign = gtk.AlignStartValue | |
| 321 | + | valign = gtk.AlignStartValue | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | t.container.SetHalign(halign) | |
| 325 | + | t.container.SetValign(valign) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // applyCustomStyle applies custom background and text colors via inline CSS. | |
| 329 | + | func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |
| 330 | + | if opts.BackgroundColor == "" && opts.TextColor == "" { | |
| 331 | + | return | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // Add custom class to indicate custom styling is applied | |
| 335 | + | t.container.AddCssClass("toast-custom") | |
| 336 | + | t.hasCustomStyle = true | |
| 337 | + | ||
| 338 | + | // Store the colors | |
| 339 | + | t.customBgColor = opts.BackgroundColor | |
| 340 | + | t.customTextColor = opts.TextColor | |
| 341 | + | ||
| 342 | + | // Remove level-based styling when using custom colors | |
| 343 | + | if opts.BackgroundColor != "" { | |
| 344 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // Generate inline CSS for custom colors | |
| 348 | + | css := ".toast-custom {" | |
| 349 | + | if opts.BackgroundColor != "" { | |
| 350 | + | css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor) | |
| 351 | + | } | |
| 352 | + | if opts.TextColor != "" { | |
| 353 | + | css += fmt.Sprintf(" color: %s;", opts.TextColor) | |
| 354 | + | } | |
| 355 | + | css += " }" | |
| 356 | + | ||
| 357 | + | // Create or reuse CSS provider for this widget | |
| 358 | + | if t.customCSSProvider == nil { | |
| 359 | + | t.customCSSProvider = gtk.NewCssProvider() | |
| 360 | + | } | |
| 361 | + | t.customCSSProvider.LoadFromString(css) | |
| 362 | + | ||
| 363 | + | // Apply to widget's style context | |
| 364 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |
| 365 | + | if styleCtx != nil { | |
| 366 | + | styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1)) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // clearCustomStyle removes any custom styling applied. | |
| 371 | + | func (t *Toaster) clearCustomStyle() { | |
| 372 | + | if t.hasCustomStyle { | |
| 373 | + | t.container.RemoveCssClass("toast-custom") | |
| 374 | + | // Remove mode-specific class if one was applied | |
| 375 | + | if t.customModeClass != "" { | |
| 376 | + | t.container.RemoveCssClass(t.customModeClass) | |
| 377 | + | t.customModeClass = "" | |
| 378 | + | } | |
| 379 | + | // Remove custom CSS provider if one was added | |
| 380 | + | if t.customCSSProvider != nil { | |
| 381 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |
| 382 | + | if styleCtx != nil { | |
| 383 | + | styleCtx.RemoveProvider(t.customCSSProvider) | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | // Re-add level class if it was removed due to custom background | |
| 387 | + | if t.customBgColor != "" { | |
| 388 | + | t.container.AddCssClass(t.currentLevel.cssClass()) | |
| 389 | + | } | |
| 390 | + | t.hasCustomStyle = false | |
| 391 | + | t.customBgColor = "" | |
| 392 | + | t.customTextColor = "" | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // applyModeClassInternal applies mode class without locking (must be called with lock held). | |
| 397 | + | func (t *Toaster) applyModeClassInternal(modeClass string) { | |
| 398 | + | // Remove level class and add mode class | |
| 399 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| 400 | + | t.container.AddCssClass(modeClass) | |
| 401 | + | t.hasCustomStyle = true | |
| 402 | + | t.customModeClass = modeClass // Store class name for cleanup tracking | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration. | |
| 175 | 406 | // Must be called with lock held. | |
| 176 | - | func (t *Toaster) startDismissTimer(ctx context.Context) { | |
| 407 | + | func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) { | |
| 177 | 408 | log := logging.FromContext(ctx) | |
| 178 | 409 | ||
| 179 | 410 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| @@ -188,7 +419,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) { | |||
| 188 | 419 | return false // Don't repeat | |
| 189 | 420 | }) | |
| 190 | 421 | ||
| 191 | - | t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0) | |
| 422 | + | t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0) | |
| 192 | 423 | } | |
| 193 | 424 | ||
| 194 | 425 | // Widget returns the underlying widget for embedding in overlays. | |
| @@ -48,6 +48,89 @@ 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 | + | // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode"). | |||
| 82 | + | // When set, this class is applied atomically with Show() to avoid visual flicker. | |||
| 83 | + | ModeClass string | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // ToastOption is a functional option for configuring toast display. | |||
| 87 | + | type ToastOption func(*ToastOptions) | |||
| 88 | + | ||||
| 89 | + | // WithDuration sets the auto-dismiss duration in milliseconds. | |||
| 90 | + | // Use 0 for persistent toasts that require manual dismissal. | |||
| 91 | + | func WithDuration(ms int) ToastOption { | |||
| 92 | + | return func(o *ToastOptions) { | |||
| 93 | + | o.Duration = ms | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // WithBackgroundColor sets a custom background color (CSS color value). | |||
| 98 | + | func WithBackgroundColor(color string) ToastOption { | |||
| 99 | + | return func(o *ToastOptions) { | |||
| 100 | + | o.BackgroundColor = color | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // WithTextColor sets a custom text color (CSS color value). | |||
| 105 | + | func WithTextColor(color string) ToastOption { | |||
| 106 | + | return func(o *ToastOptions) { | |||
| 107 | + | o.TextColor = color | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // WithPosition sets the toast position on screen. | |||
| 112 | + | func WithPosition(pos ToastPosition) ToastOption { | |||
| 113 | + | return func(o *ToastOptions) { | |||
| 114 | + | o.Position = pos | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // WithModeClass sets a mode-specific CSS class for styling. | |||
| 119 | + | // This is applied atomically within Show() to avoid visual flicker. | |||
| 120 | + | func WithModeClass(class string) ToastOption { | |||
| 121 | + | return func(o *ToastOptions) { | |||
| 122 | + | o.ModeClass = class | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // defaultToastOptions returns the default toast options. | |||
| 127 | + | func defaultToastOptions() ToastOptions { | |||
| 128 | + | return ToastOptions{ | |||
| 129 | + | Duration: toastDismissTimeoutMs, | |||
| 130 | + | Position: ToastPositionTopLeft, | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 51 | // Toaster displays toast notifications in an overlay. | 134 | // Toaster displays toast notifications in an overlay. | ||
| 52 | // It supports different notification levels and auto-dismissal. | 135 | // It supports different notification levels and auto-dismissal. | ||
| 53 | // When a new toast is shown while one is already visible, the text | 136 | // When a new toast is shown while one is already visible, the text | ||
| @@ -60,18 +143,26 @@ type Toaster struct { | |||||
| 60 | visible bool | 143 | visible bool | ||
| 61 | dismissTimer uint // GLib timer source ID | 144 | dismissTimer uint // GLib timer source ID | ||
| 62 | 145 | ||||
| 146 | + | // Track current options for cleanup | |||
| 147 | + | currentOpts ToastOptions | |||
| 148 | + | hasCustomStyle bool | |||
| 149 | + | customBgColor string // Current custom background color | |||
| 150 | + | customTextColor string // Current custom text color | |||
| 151 | + | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") | |||
| 152 | + | customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors | |||
| 153 | + | ||||
| 63 | mu sync.Mutex | 154 | mu sync.Mutex | ||
| 64 | } | 155 | } | ||
| 65 | 156 | ||||
| 66 | // NewToaster creates a new toaster component for overlay display. | 157 | // NewToaster creates a new toaster component for overlay display. | ||
| 67 | - | // The toaster is positioned in the top-left corner with margin. | 158 | + | // The toaster is positioned in the top-left corner with margin by default. |
| 68 | func NewToaster(factory layout.WidgetFactory) *Toaster { | 159 | func NewToaster(factory layout.WidgetFactory) *Toaster { | ||
| 69 | // Create container box for styling and positioning | 160 | // Create container box for styling and positioning | ||
| 70 | container := factory.NewBox(layout.OrientationHorizontal, 0) | 161 | container := factory.NewBox(layout.OrientationHorizontal, 0) | ||
| 71 | container.AddCssClass("toast") | 162 | container.AddCssClass("toast") | ||
| 72 | container.AddCssClass("toast-info") // Default level | 163 | container.AddCssClass("toast-info") // Default level | ||
| 73 | 164 | ||||
| 74 | - | // Position in top-left with margin | 165 | + | // Position in top-left with margin (default) |
| 75 | container.SetHalign(gtk.AlignStartValue) | 166 | container.SetHalign(gtk.AlignStartValue) | ||
| 76 | container.SetValign(gtk.AlignStartValue) | 167 | container.SetValign(gtk.AlignStartValue) | ||
| 77 | 168 | ||||
| @@ -98,17 +189,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster { | |||||
| 98 | label: label, | 189 | label: label, | ||
| 99 | currentLevel: ToastInfo, | 190 | currentLevel: ToastInfo, | ||
| 100 | visible: false, | 191 | visible: false, | ||
| 192 | + | currentOpts: defaultToastOptions(), | |||
| 101 | } | 193 | } | ||
| 102 | } | 194 | } | ||
| 103 | 195 | ||||
| 104 | // Show displays a toast notification with the given message and level. | 196 | // 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. | 197 | // 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) { | 198 | + | // Optional ToastOption arguments can customize duration, colors, and position. |
| 199 | + | func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) { | |||
| 107 | log := logging.FromContext(ctx) | 200 | log := logging.FromContext(ctx) | ||
| 108 | 201 | ||||
| 202 | + | // Build options from defaults and overrides | |||
| 203 | + | options := defaultToastOptions() | |||
| 204 | + | for _, opt := range opts { | |||
| 205 | + | opt(&options) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 109 | t.mu.Lock() | 208 | t.mu.Lock() | ||
| 110 | defer t.mu.Unlock() | 209 | defer t.mu.Unlock() | ||
| 111 | 210 | ||||
| 211 | + | // Clear previous custom styles if any | |||
| 212 | + | t.clearCustomStyle() | |||
| 213 | + | ||||
| 112 | // Update level CSS class if changed | 214 | // Update level CSS class if changed | ||
| 113 | if t.currentLevel != level { | 215 | if t.currentLevel != level { | ||
| 114 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | 216 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | ||
| @@ -116,6 +218,17 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||||
| 116 | t.currentLevel = level | 218 | t.currentLevel = level | ||
| 117 | } | 219 | } | ||
| 118 | 220 | ||||
| 221 | + | // Apply custom colors if specified | |||
| 222 | + | t.applyCustomStyle(options) | |||
| 223 | + | ||||
| 224 | + | // Apply mode class if specified (atomically to avoid flicker) | |||
| 225 | + | if options.ModeClass != "" { | |||
| 226 | + | t.applyModeClassInternal(options.ModeClass) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // Apply position | |||
| 230 | + | t.applyPosition(options.Position) | |||
| 231 | + | ||||
| 119 | // Update message text | 232 | // Update message text | ||
| 120 | t.label.SetText(message) | 233 | t.label.SetText(message) | ||
| 121 | 234 | ||||
| @@ -131,12 +244,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||||
| 131 | t.container.SetVisible(true) | 244 | t.container.SetVisible(true) | ||
| 132 | } | 245 | } | ||
| 133 | 246 | ||||
| 134 | - | // Start new dismiss timer | 247 | + | // Start new dismiss timer (if duration > 0) |
| 135 | - | t.startDismissTimer(ctx) | 248 | + | if options.Duration > 0 { |
| 249 | + | t.startDismissTimerWithDuration(ctx, options.Duration) | |||
| 250 | + | } | |||
| 251 | + | // Duration == 0 means persistent, no timer | |||
| 252 | + | ||||
| 253 | + | t.currentOpts = options | |||
| 136 | 254 | ||||
| 137 | log.Debug(). | 255 | log.Debug(). | ||
| 138 | Str("toast_message", message). | 256 | Str("toast_message", message). | ||
| 139 | Int("toast_level", int(level)). | 257 | Int("toast_level", int(level)). | ||
| 258 | + | Int("duration", options.Duration). | |||
| 140 | Msg("toast shown") | 259 | Msg("toast shown") | ||
| 141 | } | 260 | } | ||
| 142 | 261 | ||||
| @@ -167,13 +286,125 @@ func (t *Toaster) hide() { | |||||
| 167 | t.dismissTimer = 0 | 286 | t.dismissTimer = 0 | ||
| 168 | } | 287 | } | ||
| 169 | 288 | ||||
| 289 | + | // Clear custom styles | |||
| 290 | + | t.clearCustomStyle() | |||
| 291 | + | ||||
| 170 | t.visible = false | 292 | t.visible = false | ||
| 171 | t.container.SetVisible(false) | 293 | t.container.SetVisible(false) | ||
| 172 | } | 294 | } | ||
| 173 | 295 | ||||
| 174 | - | // startDismissTimer starts the auto-dismiss timer. | 296 | + | // applyPosition sets the widget alignment based on the position. |
| 297 | + | func (t *Toaster) applyPosition(pos ToastPosition) { | |||
| 298 | + | var halign, valign gtk.Align | |||
| 299 | + | ||||
| 300 | + | switch pos { | |||
| 301 | + | case ToastPositionTopLeft: | |||
| 302 | + | halign = gtk.AlignStartValue | |||
| 303 | + | valign = gtk.AlignStartValue | |||
| 304 | + | case ToastPositionTopCenter: | |||
| 305 | + | halign = gtk.AlignCenterValue | |||
| 306 | + | valign = gtk.AlignStartValue | |||
| 307 | + | case ToastPositionTopRight: | |||
| 308 | + | halign = gtk.AlignEndValue | |||
| 309 | + | valign = gtk.AlignStartValue | |||
| 310 | + | case ToastPositionBottomLeft: | |||
| 311 | + | halign = gtk.AlignStartValue | |||
| 312 | + | valign = gtk.AlignEndValue | |||
| 313 | + | case ToastPositionBottomCenter: | |||
| 314 | + | halign = gtk.AlignCenterValue | |||
| 315 | + | valign = gtk.AlignEndValue | |||
| 316 | + | case ToastPositionBottomRight: | |||
| 317 | + | halign = gtk.AlignEndValue | |||
| 318 | + | valign = gtk.AlignEndValue | |||
| 319 | + | default: | |||
| 320 | + | halign = gtk.AlignStartValue | |||
| 321 | + | valign = gtk.AlignStartValue | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | t.container.SetHalign(halign) | |||
| 325 | + | t.container.SetValign(valign) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // applyCustomStyle applies custom background and text colors via inline CSS. | |||
| 329 | + | func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||
| 330 | + | if opts.BackgroundColor == "" && opts.TextColor == "" { | |||
| 331 | + | return | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // Add custom class to indicate custom styling is applied | |||
| 335 | + | t.container.AddCssClass("toast-custom") | |||
| 336 | + | t.hasCustomStyle = true | |||
| 337 | + | ||||
| 338 | + | // Store the colors | |||
| 339 | + | t.customBgColor = opts.BackgroundColor | |||
| 340 | + | t.customTextColor = opts.TextColor | |||
| 341 | + | ||||
| 342 | + | // Remove level-based styling when using custom colors | |||
| 343 | + | if opts.BackgroundColor != "" { | |||
| 344 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // Generate inline CSS for custom colors | |||
| 348 | + | css := ".toast-custom {" | |||
| 349 | + | if opts.BackgroundColor != "" { | |||
| 350 | + | css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor) | |||
| 351 | + | } | |||
| 352 | + | if opts.TextColor != "" { | |||
| 353 | + | css += fmt.Sprintf(" color: %s;", opts.TextColor) | |||
| 354 | + | } | |||
| 355 | + | css += " }" | |||
| 356 | + | ||||
| 357 | + | // Create or reuse CSS provider for this widget | |||
| 358 | + | if t.customCSSProvider == nil { | |||
| 359 | + | t.customCSSProvider = gtk.NewCssProvider() | |||
| 360 | + | } | |||
| 361 | + | t.customCSSProvider.LoadFromString(css) | |||
| 362 | + | ||||
| 363 | + | // Apply to widget's style context | |||
| 364 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |||
| 365 | + | if styleCtx != nil { | |||
| 366 | + | styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1)) | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // clearCustomStyle removes any custom styling applied. | |||
| 371 | + | func (t *Toaster) clearCustomStyle() { | |||
| 372 | + | if t.hasCustomStyle { | |||
| 373 | + | t.container.RemoveCssClass("toast-custom") | |||
| 374 | + | // Remove mode-specific class if one was applied | |||
| 375 | + | if t.customModeClass != "" { | |||
| 376 | + | t.container.RemoveCssClass(t.customModeClass) | |||
| 377 | + | t.customModeClass = "" | |||
| 378 | + | } | |||
| 379 | + | // Remove custom CSS provider if one was added | |||
| 380 | + | if t.customCSSProvider != nil { | |||
| 381 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |||
| 382 | + | if styleCtx != nil { | |||
| 383 | + | styleCtx.RemoveProvider(t.customCSSProvider) | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | // Re-add level class if it was removed due to custom background | |||
| 387 | + | if t.customBgColor != "" { | |||
| 388 | + | t.container.AddCssClass(t.currentLevel.cssClass()) | |||
| 389 | + | } | |||
| 390 | + | t.hasCustomStyle = false | |||
| 391 | + | t.customBgColor = "" | |||
| 392 | + | t.customTextColor = "" | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | // applyModeClassInternal applies mode class without locking (must be called with lock held). | |||
| 397 | + | func (t *Toaster) applyModeClassInternal(modeClass string) { | |||
| 398 | + | // Remove level class and add mode class | |||
| 399 | + | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |||
| 400 | + | t.container.AddCssClass(modeClass) | |||
| 401 | + | t.hasCustomStyle = true | |||
| 402 | + | t.customModeClass = modeClass // Store class name for cleanup tracking | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration. | |||
| 175 | // Must be called with lock held. | 406 | // Must be called with lock held. | ||
| 176 | - | func (t *Toaster) startDismissTimer(ctx context.Context) { | 407 | + | func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) { |
| 177 | log := logging.FromContext(ctx) | 408 | log := logging.FromContext(ctx) | ||
| 178 | 409 | ||||
| 179 | cb := glib.SourceFunc(func(_ uintptr) bool { | 410 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| @@ -188,7 +419,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) { | |||||
| 188 | return false // Don't repeat | 419 | return false // Don't repeat | ||
| 189 | }) | 420 | }) | ||
| 190 | 421 | ||||
| 191 | - | t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0) | 422 | + | t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0) |
| 192 | } | 423 | } | ||
| 193 | 424 | ||||
| 194 | // Widget returns the underlying widget for embedding in overlays. | 425 | // 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{ | ||