Merge pull request #57 from bnema/misc-fixes
Misc fixes: omnibox toggle, config migrate, config open, config schema
19 files · +2181 -65
M .mockery.yaml +1 -0
| @@ -9,6 +9,7 @@ packages: | |||
| 9 | 9 | pkgname: mocks | |
| 10 | 10 | interfaces: | |
| 11 | 11 | ConfigMigrator: {} | |
| 12 | + | ConfigSchemaProvider: {} | |
| 12 | 13 | DatabaseProvider: {} | |
| 13 | 14 | DiffFormatter: {} | |
| 14 | 15 | SessionLogger: {} | |
| @@ -9,6 +9,7 @@ packages: | |||||
| 9 | pkgname: mocks | 9 | pkgname: mocks | ||
| 10 | interfaces: | 10 | interfaces: | ||
| 11 | ConfigMigrator: {} | 11 | ConfigMigrator: {} | ||
| 12 | + | ConfigSchemaProvider: {} | |||
| 12 | DatabaseProvider: {} | 13 | DatabaseProvider: {} | ||
| 13 | DiffFormatter: {} | 14 | DiffFormatter: {} | ||
| 14 | SessionLogger: {} | 15 | SessionLogger: {} | ||
M CHANGELOG.md +4 -0
| @@ -19,6 +19,8 @@ All notable changes to this project will be documented in this file. | |||
| 19 | 19 | - `--force` / `-f` flag to skip version check and reinstall the current version. | |
| 20 | 20 | - Reuses existing auto-update infrastructure (check, download, stage, apply on exit). | |
| 21 | 21 | - Theme-aware styling consistent with other CLI commands. | |
| 22 | + | - **Config open command**: New `dumber config open` command to open the config file in your preferred editor. Uses `$EDITOR`, then `$VISUAL`, with fallback to nano/vim/vi. | |
| 23 | + | - **Config schema command**: New `dumber config schema` command that displays all configuration keys with their types, defaults, descriptions, and valid values. Supports `--json` flag for machine-readable output. | |
| 22 | 24 | - **Move pane to tab**: New pane-mode actions to move the active pane to another tab. | |
| 23 | 25 | - `Ctrl+P → m`: Opens a tab picker modal. | |
| 24 | 26 | - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed). | |
| @@ -40,7 +42,9 @@ All notable changes to this project will be documented in this file. | |||
| 40 | 42 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | |
| 41 | 43 | ||
| 42 | 44 | ### Fixed | |
| 45 | + | - **Omnibox favorite toggle reactivity**: Fixed space key toggle in omnibox not updating UI reactively. Now properly toggles favorites (add if not favorite, remove if favorite) with immediate visual feedback (yellow indicator) and toast notifications. Business logic moved to `ManageFavoritesUseCase.Toggle()` for testability. | |
| 43 | 46 | - **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. | |
| 47 | + | - **Config migrate removes deprecated settings**: Fixed `dumber config migrate` not removing deprecated settings from the config file. Previously deprecated keys were preserved but unused; now they are properly deleted. Supports TOML, YAML, and JSON config formats. | |
| 44 | 48 | - **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. | |
| 45 | 49 | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | |
| 46 | 50 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | |
| @@ -19,6 +19,8 @@ All notable changes to this project will be documented in this file. | |||||
| 19 | - `--force` / `-f` flag to skip version check and reinstall the current version. | 19 | - `--force` / `-f` flag to skip version check and reinstall the current version. | ||
| 20 | - Reuses existing auto-update infrastructure (check, download, stage, apply on exit). | 20 | - Reuses existing auto-update infrastructure (check, download, stage, apply on exit). | ||
| 21 | - Theme-aware styling consistent with other CLI commands. | 21 | - Theme-aware styling consistent with other CLI commands. | ||
| 22 | + | - **Config open command**: New `dumber config open` command to open the config file in your preferred editor. Uses `$EDITOR`, then `$VISUAL`, with fallback to nano/vim/vi. | |||
| 23 | + | - **Config schema command**: New `dumber config schema` command that displays all configuration keys with their types, defaults, descriptions, and valid values. Supports `--json` flag for machine-readable output. | |||
| 22 | - **Move pane to tab**: New pane-mode actions to move the active pane to another tab. | 24 | - **Move pane to tab**: New pane-mode actions to move the active pane to another tab. | ||
| 23 | - `Ctrl+P → m`: Opens a tab picker modal. | 25 | - `Ctrl+P → m`: Opens a tab picker modal. | ||
| 24 | - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed). | 26 | - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed). | ||
| @@ -40,7 +42,9 @@ All notable changes to this project will be documented in this file. | |||||
| 40 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | 42 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | ||
| 41 | 43 | ||||
| 42 | ### Fixed | 44 | ### Fixed | ||
| 45 | + | - **Omnibox favorite toggle reactivity**: Fixed space key toggle in omnibox not updating UI reactively. Now properly toggles favorites (add if not favorite, remove if favorite) with immediate visual feedback (yellow indicator) and toast notifications. Business logic moved to `ManageFavoritesUseCase.Toggle()` for testability. | |||
| 43 | - **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. | 46 | - **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. | ||
| 47 | + | - **Config migrate removes deprecated settings**: Fixed `dumber config migrate` not removing deprecated settings from the config file. Previously deprecated keys were preserved but unused; now they are properly deleted. Supports TOML, YAML, and JSON config formats. | |||
| 44 | - **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. | 48 | - **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. | ||
| 45 | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | 49 | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | ||
| 46 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | 50 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | ||
M internal/application/port/config_migrator.go +5 -2
| @@ -63,12 +63,15 @@ type ConfigMigrator interface { | |||
| 63 | 63 | // This provides a detailed diff-like view of what migration would do. | |
| 64 | 64 | DetectChanges() ([]KeyChange, error) | |
| 65 | 65 | ||
| 66 | - | // Migrate adds missing default keys to the user's config file. | |
| 67 | - | // Returns the list of keys that were added/renamed. | |
| 66 | + | // Migrate adds missing default keys and removes deprecated keys from the user's config file. | |
| 67 | + | // Returns the list of keys that were added/renamed/removed. | |
| 68 | 68 | Migrate() ([]string, error) | |
| 69 | 69 | ||
| 70 | 70 | // GetKeyInfo returns detailed information about a config key. | |
| 71 | 71 | GetKeyInfo(key string) KeyInfo | |
| 72 | + | ||
| 73 | + | // GetConfigFile returns the path to the user's config file. | |
| 74 | + | GetConfigFile() (string, error) | |
| 72 | 75 | } | |
| 73 | 76 | ||
| 74 | 77 | // DiffFormatter formats config changes for display. | |
| @@ -63,12 +63,15 @@ type ConfigMigrator interface { | |||||
| 63 | // This provides a detailed diff-like view of what migration would do. | 63 | // This provides a detailed diff-like view of what migration would do. | ||
| 64 | DetectChanges() ([]KeyChange, error) | 64 | DetectChanges() ([]KeyChange, error) | ||
| 65 | 65 | ||||
| 66 | - | // Migrate adds missing default keys to the user's config file. | 66 | + | // Migrate adds missing default keys and removes deprecated keys from the user's config file. |
| 67 | - | // Returns the list of keys that were added/renamed. | 67 | + | // Returns the list of keys that were added/renamed/removed. |
| 68 | Migrate() ([]string, error) | 68 | Migrate() ([]string, error) | ||
| 69 | 69 | ||||
| 70 | // GetKeyInfo returns detailed information about a config key. | 70 | // GetKeyInfo returns detailed information about a config key. | ||
| 71 | GetKeyInfo(key string) KeyInfo | 71 | GetKeyInfo(key string) KeyInfo | ||
| 72 | + | ||||
| 73 | + | // GetConfigFile returns the path to the user's config file. | |||
| 74 | + | GetConfigFile() (string, error) | |||
| 72 | } | 75 | } | ||
| 73 | 76 | ||||
| 74 | // DiffFormatter formats config changes for display. | 77 | // DiffFormatter formats config changes for display. | ||
A internal/application/port/config_schema.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |
| 4 | + | ||
| 5 | + | // ConfigSchemaProvider provides configuration schema information. | |
| 6 | + | type ConfigSchemaProvider interface { | |
| 7 | + | // GetSchema returns all configuration keys with their metadata. | |
| 8 | + | GetSchema() []entity.ConfigKeyInfo | |
| 9 | + | } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |||
| 4 | + | ||||
| 5 | + | // ConfigSchemaProvider provides configuration schema information. | |||
| 6 | + | type ConfigSchemaProvider interface { | |||
| 7 | + | // GetSchema returns all configuration keys with their metadata. | |||
| 8 | + | GetSchema() []entity.ConfigKeyInfo | |||
| 9 | + | } | |||
M internal/application/port/mocks/mocks.go +126 -0
| @@ -151,6 +151,59 @@ func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port | |||
| 151 | 151 | return _c | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | + | // GetConfigFile provides a mock function for the type MockConfigMigrator | |
| 155 | + | func (_mock *MockConfigMigrator) GetConfigFile() (string, error) { | |
| 156 | + | ret := _mock.Called() | |
| 157 | + | ||
| 158 | + | if len(ret) == 0 { | |
| 159 | + | panic("no return value specified for GetConfigFile") | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | var r0 string | |
| 163 | + | var r1 error | |
| 164 | + | if returnFunc, ok := ret.Get(0).(func() (string, error)); ok { | |
| 165 | + | return returnFunc() | |
| 166 | + | } | |
| 167 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 168 | + | r0 = returnFunc() | |
| 169 | + | } else { | |
| 170 | + | r0 = ret.Get(0).(string) | |
| 171 | + | } | |
| 172 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 173 | + | r1 = returnFunc() | |
| 174 | + | } else { | |
| 175 | + | r1 = ret.Error(1) | |
| 176 | + | } | |
| 177 | + | return r0, r1 | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // MockConfigMigrator_GetConfigFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfigFile' | |
| 181 | + | type MockConfigMigrator_GetConfigFile_Call struct { | |
| 182 | + | *mock.Call | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // GetConfigFile is a helper method to define mock.On call | |
| 186 | + | func (_e *MockConfigMigrator_Expecter) GetConfigFile() *MockConfigMigrator_GetConfigFile_Call { | |
| 187 | + | return &MockConfigMigrator_GetConfigFile_Call{Call: _e.mock.On("GetConfigFile")} | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) Run(run func()) *MockConfigMigrator_GetConfigFile_Call { | |
| 191 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 192 | + | run() | |
| 193 | + | }) | |
| 194 | + | return _c | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) Return(s string, err error) *MockConfigMigrator_GetConfigFile_Call { | |
| 198 | + | _c.Call.Return(s, err) | |
| 199 | + | return _c | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) RunAndReturn(run func() (string, error)) *MockConfigMigrator_GetConfigFile_Call { | |
| 203 | + | _c.Call.Return(run) | |
| 204 | + | return _c | |
| 205 | + | } | |
| 206 | + | ||
| 154 | 207 | // GetKeyInfo provides a mock function for the type MockConfigMigrator | |
| 155 | 208 | func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo { | |
| 156 | 209 | ret := _mock.Called(key) | |
| @@ -335,6 +388,79 @@ func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(chan | |||
| 335 | 388 | return _c | |
| 336 | 389 | } | |
| 337 | 390 | ||
| 391 | + | // NewMockConfigSchemaProvider creates a new instance of MockConfigSchemaProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 392 | + | // The first argument is typically a *testing.T value. | |
| 393 | + | func NewMockConfigSchemaProvider(t interface { | |
| 394 | + | mock.TestingT | |
| 395 | + | Cleanup(func()) | |
| 396 | + | }) *MockConfigSchemaProvider { | |
| 397 | + | mock := &MockConfigSchemaProvider{} | |
| 398 | + | mock.Mock.Test(t) | |
| 399 | + | ||
| 400 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 401 | + | ||
| 402 | + | return mock | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // MockConfigSchemaProvider is an autogenerated mock type for the ConfigSchemaProvider type | |
| 406 | + | type MockConfigSchemaProvider struct { | |
| 407 | + | mock.Mock | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | type MockConfigSchemaProvider_Expecter struct { | |
| 411 | + | mock *mock.Mock | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func (_m *MockConfigSchemaProvider) EXPECT() *MockConfigSchemaProvider_Expecter { | |
| 415 | + | return &MockConfigSchemaProvider_Expecter{mock: &_m.Mock} | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // GetSchema provides a mock function for the type MockConfigSchemaProvider | |
| 419 | + | func (_mock *MockConfigSchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |
| 420 | + | ret := _mock.Called() | |
| 421 | + | ||
| 422 | + | if len(ret) == 0 { | |
| 423 | + | panic("no return value specified for GetSchema") | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | var r0 []entity.ConfigKeyInfo | |
| 427 | + | if returnFunc, ok := ret.Get(0).(func() []entity.ConfigKeyInfo); ok { | |
| 428 | + | r0 = returnFunc() | |
| 429 | + | } else { | |
| 430 | + | if ret.Get(0) != nil { | |
| 431 | + | r0 = ret.Get(0).([]entity.ConfigKeyInfo) | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | return r0 | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // MockConfigSchemaProvider_GetSchema_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSchema' | |
| 438 | + | type MockConfigSchemaProvider_GetSchema_Call struct { | |
| 439 | + | *mock.Call | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // GetSchema is a helper method to define mock.On call | |
| 443 | + | func (_e *MockConfigSchemaProvider_Expecter) GetSchema() *MockConfigSchemaProvider_GetSchema_Call { | |
| 444 | + | return &MockConfigSchemaProvider_GetSchema_Call{Call: _e.mock.On("GetSchema")} | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) Run(run func()) *MockConfigSchemaProvider_GetSchema_Call { | |
| 448 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 449 | + | run() | |
| 450 | + | }) | |
| 451 | + | return _c | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) Return(configKeyInfos []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call { | |
| 455 | + | _c.Call.Return(configKeyInfos) | |
| 456 | + | return _c | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call { | |
| 460 | + | _c.Call.Return(run) | |
| 461 | + | return _c | |
| 462 | + | } | |
| 463 | + | ||
| 338 | 464 | // 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. | |
| 339 | 465 | // The first argument is typically a *testing.T value. | |
| 340 | 466 | func NewMockDatabaseProvider(t interface { | |
| @@ -151,6 +151,59 @@ func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port | |||||
| 151 | return _c | 151 | return _c | ||
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | + | // GetConfigFile provides a mock function for the type MockConfigMigrator | |||
| 155 | + | func (_mock *MockConfigMigrator) GetConfigFile() (string, error) { | |||
| 156 | + | ret := _mock.Called() | |||
| 157 | + | ||||
| 158 | + | if len(ret) == 0 { | |||
| 159 | + | panic("no return value specified for GetConfigFile") | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | var r0 string | |||
| 163 | + | var r1 error | |||
| 164 | + | if returnFunc, ok := ret.Get(0).(func() (string, error)); ok { | |||
| 165 | + | return returnFunc() | |||
| 166 | + | } | |||
| 167 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 168 | + | r0 = returnFunc() | |||
| 169 | + | } else { | |||
| 170 | + | r0 = ret.Get(0).(string) | |||
| 171 | + | } | |||
| 172 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 173 | + | r1 = returnFunc() | |||
| 174 | + | } else { | |||
| 175 | + | r1 = ret.Error(1) | |||
| 176 | + | } | |||
| 177 | + | return r0, r1 | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // MockConfigMigrator_GetConfigFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfigFile' | |||
| 181 | + | type MockConfigMigrator_GetConfigFile_Call struct { | |||
| 182 | + | *mock.Call | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // GetConfigFile is a helper method to define mock.On call | |||
| 186 | + | func (_e *MockConfigMigrator_Expecter) GetConfigFile() *MockConfigMigrator_GetConfigFile_Call { | |||
| 187 | + | return &MockConfigMigrator_GetConfigFile_Call{Call: _e.mock.On("GetConfigFile")} | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) Run(run func()) *MockConfigMigrator_GetConfigFile_Call { | |||
| 191 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 192 | + | run() | |||
| 193 | + | }) | |||
| 194 | + | return _c | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) Return(s string, err error) *MockConfigMigrator_GetConfigFile_Call { | |||
| 198 | + | _c.Call.Return(s, err) | |||
| 199 | + | return _c | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) RunAndReturn(run func() (string, error)) *MockConfigMigrator_GetConfigFile_Call { | |||
| 203 | + | _c.Call.Return(run) | |||
| 204 | + | return _c | |||
| 205 | + | } | |||
| 206 | + | ||||
| 154 | // GetKeyInfo provides a mock function for the type MockConfigMigrator | 207 | // GetKeyInfo provides a mock function for the type MockConfigMigrator | ||
| 155 | func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo { | 208 | func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo { | ||
| 156 | ret := _mock.Called(key) | 209 | ret := _mock.Called(key) | ||
| @@ -335,6 +388,79 @@ func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(chan | |||||
| 335 | return _c | 388 | return _c | ||
| 336 | } | 389 | } | ||
| 337 | 390 | ||||
| 391 | + | // NewMockConfigSchemaProvider creates a new instance of MockConfigSchemaProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 392 | + | // The first argument is typically a *testing.T value. | |||
| 393 | + | func NewMockConfigSchemaProvider(t interface { | |||
| 394 | + | mock.TestingT | |||
| 395 | + | Cleanup(func()) | |||
| 396 | + | }) *MockConfigSchemaProvider { | |||
| 397 | + | mock := &MockConfigSchemaProvider{} | |||
| 398 | + | mock.Mock.Test(t) | |||
| 399 | + | ||||
| 400 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 401 | + | ||||
| 402 | + | return mock | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // MockConfigSchemaProvider is an autogenerated mock type for the ConfigSchemaProvider type | |||
| 406 | + | type MockConfigSchemaProvider struct { | |||
| 407 | + | mock.Mock | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | type MockConfigSchemaProvider_Expecter struct { | |||
| 411 | + | mock *mock.Mock | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func (_m *MockConfigSchemaProvider) EXPECT() *MockConfigSchemaProvider_Expecter { | |||
| 415 | + | return &MockConfigSchemaProvider_Expecter{mock: &_m.Mock} | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | // GetSchema provides a mock function for the type MockConfigSchemaProvider | |||
| 419 | + | func (_mock *MockConfigSchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |||
| 420 | + | ret := _mock.Called() | |||
| 421 | + | ||||
| 422 | + | if len(ret) == 0 { | |||
| 423 | + | panic("no return value specified for GetSchema") | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | var r0 []entity.ConfigKeyInfo | |||
| 427 | + | if returnFunc, ok := ret.Get(0).(func() []entity.ConfigKeyInfo); ok { | |||
| 428 | + | r0 = returnFunc() | |||
| 429 | + | } else { | |||
| 430 | + | if ret.Get(0) != nil { | |||
| 431 | + | r0 = ret.Get(0).([]entity.ConfigKeyInfo) | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | return r0 | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | // MockConfigSchemaProvider_GetSchema_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSchema' | |||
| 438 | + | type MockConfigSchemaProvider_GetSchema_Call struct { | |||
| 439 | + | *mock.Call | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // GetSchema is a helper method to define mock.On call | |||
| 443 | + | func (_e *MockConfigSchemaProvider_Expecter) GetSchema() *MockConfigSchemaProvider_GetSchema_Call { | |||
| 444 | + | return &MockConfigSchemaProvider_GetSchema_Call{Call: _e.mock.On("GetSchema")} | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) Run(run func()) *MockConfigSchemaProvider_GetSchema_Call { | |||
| 448 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 449 | + | run() | |||
| 450 | + | }) | |||
| 451 | + | return _c | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) Return(configKeyInfos []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call { | |||
| 455 | + | _c.Call.Return(configKeyInfos) | |||
| 456 | + | return _c | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call { | |||
| 460 | + | _c.Call.Return(run) | |||
| 461 | + | return _c | |||
| 462 | + | } | |||
| 463 | + | ||||
| 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. | 464 | // 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. | ||
| 339 | // The first argument is typically a *testing.T value. | 465 | // The first argument is typically a *testing.T value. | ||
| 340 | func NewMockDatabaseProvider(t interface { | 466 | func NewMockDatabaseProvider(t interface { | ||
A internal/application/usecase/get_config_schema.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // GetConfigSchemaUseCase retrieves configuration schema information. | |
| 11 | + | type GetConfigSchemaUseCase struct { | |
| 12 | + | provider port.ConfigSchemaProvider | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // NewGetConfigSchemaUseCase creates a new GetConfigSchemaUseCase. | |
| 16 | + | func NewGetConfigSchemaUseCase(provider port.ConfigSchemaProvider) *GetConfigSchemaUseCase { | |
| 17 | + | return &GetConfigSchemaUseCase{ | |
| 18 | + | provider: provider, | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // GetConfigSchemaInput contains input parameters for schema retrieval. | |
| 23 | + | type GetConfigSchemaInput struct { | |
| 24 | + | // Reserved for future filtering options (e.g., by section) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // GetConfigSchemaOutput contains the schema information. | |
| 28 | + | type GetConfigSchemaOutput struct { | |
| 29 | + | Keys []entity.ConfigKeyInfo | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Execute retrieves all configuration keys with their metadata. | |
| 33 | + | func (uc *GetConfigSchemaUseCase) Execute(_ context.Context, _ GetConfigSchemaInput) (*GetConfigSchemaOutput, error) { | |
| 34 | + | keys := uc.provider.GetSchema() | |
| 35 | + | return &GetConfigSchemaOutput{ | |
| 36 | + | Keys: keys, | |
| 37 | + | }, nil | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // GetConfigSchemaUseCase retrieves configuration schema information. | |||
| 11 | + | type GetConfigSchemaUseCase struct { | |||
| 12 | + | provider port.ConfigSchemaProvider | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // NewGetConfigSchemaUseCase creates a new GetConfigSchemaUseCase. | |||
| 16 | + | func NewGetConfigSchemaUseCase(provider port.ConfigSchemaProvider) *GetConfigSchemaUseCase { | |||
| 17 | + | return &GetConfigSchemaUseCase{ | |||
| 18 | + | provider: provider, | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // GetConfigSchemaInput contains input parameters for schema retrieval. | |||
| 23 | + | type GetConfigSchemaInput struct { | |||
| 24 | + | // Reserved for future filtering options (e.g., by section) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // GetConfigSchemaOutput contains the schema information. | |||
| 28 | + | type GetConfigSchemaOutput struct { | |||
| 29 | + | Keys []entity.ConfigKeyInfo | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Execute retrieves all configuration keys with their metadata. | |||
| 33 | + | func (uc *GetConfigSchemaUseCase) Execute(_ context.Context, _ GetConfigSchemaInput) (*GetConfigSchemaOutput, error) { | |||
| 34 | + | keys := uc.provider.GetSchema() | |||
| 35 | + | return &GetConfigSchemaOutput{ | |||
| 36 | + | Keys: keys, | |||
| 37 | + | }, nil | |||
| 38 | + | } | |||
A internal/application/usecase/get_config_schema_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestGetConfigSchemaUseCase_Execute(t *testing.T) { | |
| 17 | + | t.Run("returns schema keys from provider", func(t *testing.T) { | |
| 18 | + | // Arrange | |
| 19 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |
| 20 | + | expectedKeys := []entity.ConfigKeyInfo{ | |
| 21 | + | { | |
| 22 | + | Key: "appearance.color_scheme", | |
| 23 | + | Type: "string", | |
| 24 | + | Default: "default", | |
| 25 | + | Description: "Theme preference", | |
| 26 | + | Values: []string{"default", "prefer-dark", "prefer-light"}, | |
| 27 | + | Section: "Appearance", | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | Key: "logging.level", | |
| 31 | + | Type: "string", | |
| 32 | + | Default: "info", | |
| 33 | + | Description: "Log verbosity level", | |
| 34 | + | Values: []string{"trace", "debug", "info", "warn", "error", "fatal"}, | |
| 35 | + | Section: "Logging", | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | mockProvider.EXPECT().GetSchema().Return(expectedKeys) | |
| 39 | + | ||
| 40 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |
| 41 | + | ||
| 42 | + | // Act | |
| 43 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |
| 44 | + | ||
| 45 | + | // Assert | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | assert.NotNil(t, result) | |
| 48 | + | assert.Len(t, result.Keys, 2) | |
| 49 | + | assert.Equal(t, "appearance.color_scheme", result.Keys[0].Key) | |
| 50 | + | assert.Equal(t, "logging.level", result.Keys[1].Key) | |
| 51 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |
| 52 | + | }) | |
| 53 | + | ||
| 54 | + | t.Run("returns empty slice when no keys", func(t *testing.T) { | |
| 55 | + | // Arrange | |
| 56 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |
| 57 | + | mockProvider.EXPECT().GetSchema().Return([]entity.ConfigKeyInfo{}) | |
| 58 | + | ||
| 59 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |
| 60 | + | ||
| 61 | + | // Act | |
| 62 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |
| 63 | + | ||
| 64 | + | // Assert | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | assert.NotNil(t, result) | |
| 67 | + | assert.Empty(t, result.Keys) | |
| 68 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |
| 69 | + | }) | |
| 70 | + | ||
| 71 | + | t.Run("keys include all expected fields", func(t *testing.T) { | |
| 72 | + | // Arrange | |
| 73 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |
| 74 | + | expectedKeys := []entity.ConfigKeyInfo{ | |
| 75 | + | { | |
| 76 | + | Key: "appearance.default_font_size", | |
| 77 | + | Type: "int", | |
| 78 | + | Default: "16", | |
| 79 | + | Description: "Default font size in CSS pixels", | |
| 80 | + | Range: "1-72", | |
| 81 | + | Section: "Appearance", | |
| 82 | + | }, | |
| 83 | + | } | |
| 84 | + | mockProvider.EXPECT().GetSchema().Return(expectedKeys) | |
| 85 | + | ||
| 86 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |
| 87 | + | ||
| 88 | + | // Act | |
| 89 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |
| 90 | + | ||
| 91 | + | // Assert | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | assert.NotNil(t, result) | |
| 94 | + | assert.Len(t, result.Keys, 1) | |
| 95 | + | ||
| 96 | + | key := result.Keys[0] | |
| 97 | + | assert.Equal(t, "appearance.default_font_size", key.Key) | |
| 98 | + | assert.Equal(t, "int", key.Type) | |
| 99 | + | assert.Equal(t, "16", key.Default) | |
| 100 | + | assert.Equal(t, "Default font size in CSS pixels", key.Description) | |
| 101 | + | assert.Equal(t, "1-72", key.Range) | |
| 102 | + | assert.Equal(t, "Appearance", key.Section) | |
| 103 | + | assert.Empty(t, key.Values) // No enum values for int type | |
| 104 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |
| 105 | + | }) | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package usecase_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestGetConfigSchemaUseCase_Execute(t *testing.T) { | |||
| 17 | + | t.Run("returns schema keys from provider", func(t *testing.T) { | |||
| 18 | + | // Arrange | |||
| 19 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |||
| 20 | + | expectedKeys := []entity.ConfigKeyInfo{ | |||
| 21 | + | { | |||
| 22 | + | Key: "appearance.color_scheme", | |||
| 23 | + | Type: "string", | |||
| 24 | + | Default: "default", | |||
| 25 | + | Description: "Theme preference", | |||
| 26 | + | Values: []string{"default", "prefer-dark", "prefer-light"}, | |||
| 27 | + | Section: "Appearance", | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | Key: "logging.level", | |||
| 31 | + | Type: "string", | |||
| 32 | + | Default: "info", | |||
| 33 | + | Description: "Log verbosity level", | |||
| 34 | + | Values: []string{"trace", "debug", "info", "warn", "error", "fatal"}, | |||
| 35 | + | Section: "Logging", | |||
| 36 | + | }, | |||
| 37 | + | } | |||
| 38 | + | mockProvider.EXPECT().GetSchema().Return(expectedKeys) | |||
| 39 | + | ||||
| 40 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |||
| 41 | + | ||||
| 42 | + | // Act | |||
| 43 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |||
| 44 | + | ||||
| 45 | + | // Assert | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | assert.NotNil(t, result) | |||
| 48 | + | assert.Len(t, result.Keys, 2) | |||
| 49 | + | assert.Equal(t, "appearance.color_scheme", result.Keys[0].Key) | |||
| 50 | + | assert.Equal(t, "logging.level", result.Keys[1].Key) | |||
| 51 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |||
| 52 | + | }) | |||
| 53 | + | ||||
| 54 | + | t.Run("returns empty slice when no keys", func(t *testing.T) { | |||
| 55 | + | // Arrange | |||
| 56 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |||
| 57 | + | mockProvider.EXPECT().GetSchema().Return([]entity.ConfigKeyInfo{}) | |||
| 58 | + | ||||
| 59 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |||
| 60 | + | ||||
| 61 | + | // Act | |||
| 62 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |||
| 63 | + | ||||
| 64 | + | // Assert | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | assert.NotNil(t, result) | |||
| 67 | + | assert.Empty(t, result.Keys) | |||
| 68 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |||
| 69 | + | }) | |||
| 70 | + | ||||
| 71 | + | t.Run("keys include all expected fields", func(t *testing.T) { | |||
| 72 | + | // Arrange | |||
| 73 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |||
| 74 | + | expectedKeys := []entity.ConfigKeyInfo{ | |||
| 75 | + | { | |||
| 76 | + | Key: "appearance.default_font_size", | |||
| 77 | + | Type: "int", | |||
| 78 | + | Default: "16", | |||
| 79 | + | Description: "Default font size in CSS pixels", | |||
| 80 | + | Range: "1-72", | |||
| 81 | + | Section: "Appearance", | |||
| 82 | + | }, | |||
| 83 | + | } | |||
| 84 | + | mockProvider.EXPECT().GetSchema().Return(expectedKeys) | |||
| 85 | + | ||||
| 86 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |||
| 87 | + | ||||
| 88 | + | // Act | |||
| 89 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |||
| 90 | + | ||||
| 91 | + | // Assert | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | assert.NotNil(t, result) | |||
| 94 | + | assert.Len(t, result.Keys, 1) | |||
| 95 | + | ||||
| 96 | + | key := result.Keys[0] | |||
| 97 | + | assert.Equal(t, "appearance.default_font_size", key.Key) | |||
| 98 | + | assert.Equal(t, "int", key.Type) | |||
| 99 | + | assert.Equal(t, "16", key.Default) | |||
| 100 | + | assert.Equal(t, "Default font size in CSS pixels", key.Description) | |||
| 101 | + | assert.Equal(t, "1-72", key.Range) | |||
| 102 | + | assert.Equal(t, "Appearance", key.Section) | |||
| 103 | + | assert.Empty(t, key.Values) // No enum values for int type | |||
| 104 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |||
| 105 | + | }) | |||
| 106 | + | } | |||
M internal/application/usecase/manage_favorites.go +52 -0
| @@ -196,6 +196,58 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b | |||
| 196 | 196 | return fav != nil, nil | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | + | // ToggleResult indicates the result of a toggle operation. | |
| 200 | + | type ToggleResult struct { | |
| 201 | + | Added bool // true if favorite was added, false if removed | |
| 202 | + | URL string | |
| 203 | + | Title string | |
| 204 | + | Message string // User-friendly message | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Toggle adds or removes a URL from favorites based on current state. | |
| 208 | + | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | |
| 209 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | |
| 210 | + | log := logging.FromContext(ctx) | |
| 211 | + | log.Debug().Str("url", url).Msg("toggling favorite") | |
| 212 | + | ||
| 213 | + | if url == "" { | |
| 214 | + | return nil, fmt.Errorf("URL cannot be empty") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Check if already favorited | |
| 218 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | |
| 219 | + | if err != nil { | |
| 220 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | if existing != nil { | |
| 224 | + | // Remove favorite | |
| 225 | + | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | |
| 226 | + | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |
| 227 | + | } | |
| 228 | + | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 229 | + | return &ToggleResult{ | |
| 230 | + | Added: false, | |
| 231 | + | URL: url, | |
| 232 | + | Title: existing.Title, | |
| 233 | + | Message: "Favorite removed", | |
| 234 | + | }, nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Add favorite | |
| 238 | + | fav := entity.NewFavorite(url, title) | |
| 239 | + | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | |
| 240 | + | return nil, fmt.Errorf("failed to add favorite: %w", err) | |
| 241 | + | } | |
| 242 | + | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 243 | + | return &ToggleResult{ | |
| 244 | + | Added: true, | |
| 245 | + | URL: url, | |
| 246 | + | Title: title, | |
| 247 | + | Message: "Favorite added", | |
| 248 | + | }, nil | |
| 249 | + | } | |
| 250 | + | ||
| 199 | 251 | // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. | |
| 200 | 252 | func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { | |
| 201 | 253 | log := logging.FromContext(ctx) | |
| @@ -196,6 +196,58 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b | |||||
| 196 | return fav != nil, nil | 196 | return fav != nil, nil | ||
| 197 | } | 197 | } | ||
| 198 | 198 | ||||
| 199 | + | // ToggleResult indicates the result of a toggle operation. | |||
| 200 | + | type ToggleResult struct { | |||
| 201 | + | Added bool // true if favorite was added, false if removed | |||
| 202 | + | URL string | |||
| 203 | + | Title string | |||
| 204 | + | Message string // User-friendly message | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Toggle adds or removes a URL from favorites based on current state. | |||
| 208 | + | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | |||
| 209 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | |||
| 210 | + | log := logging.FromContext(ctx) | |||
| 211 | + | log.Debug().Str("url", url).Msg("toggling favorite") | |||
| 212 | + | ||||
| 213 | + | if url == "" { | |||
| 214 | + | return nil, fmt.Errorf("URL cannot be empty") | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // Check if already favorited | |||
| 218 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | |||
| 219 | + | if err != nil { | |||
| 220 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | if existing != nil { | |||
| 224 | + | // Remove favorite | |||
| 225 | + | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | |||
| 226 | + | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |||
| 227 | + | } | |||
| 228 | + | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |||
| 229 | + | return &ToggleResult{ | |||
| 230 | + | Added: false, | |||
| 231 | + | URL: url, | |||
| 232 | + | Title: existing.Title, | |||
| 233 | + | Message: "Favorite removed", | |||
| 234 | + | }, nil | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // Add favorite | |||
| 238 | + | fav := entity.NewFavorite(url, title) | |||
| 239 | + | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | |||
| 240 | + | return nil, fmt.Errorf("failed to add favorite: %w", err) | |||
| 241 | + | } | |||
| 242 | + | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |||
| 243 | + | return &ToggleResult{ | |||
| 244 | + | Added: true, | |||
| 245 | + | URL: url, | |||
| 246 | + | Title: title, | |||
| 247 | + | Message: "Favorite added", | |||
| 248 | + | }, nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 199 | // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. | 251 | // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. | ||
| 200 | func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { | 252 | func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { | ||
| 201 | log := logging.FromContext(ctx) | 253 | log := logging.FromContext(ctx) | ||
M internal/application/usecase/manage_favorites_test.go +123 -0
| @@ -75,3 +75,126 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing. | |||
| 75 | 75 | assert.Nil(t, urls) | |
| 76 | 76 | assert.Contains(t, err.Error(), "failed to get favorites") | |
| 77 | 77 | } | |
| 78 | + | ||
| 79 | + | func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) { | |
| 80 | + | ctx := testContext() | |
| 81 | + | ||
| 82 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 83 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 84 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 85 | + | ||
| 86 | + | // URL is not a favorite | |
| 87 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |
| 88 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool { | |
| 89 | + | return f.URL == "https://example.com" && f.Title == "Example" | |
| 90 | + | })).Return(nil) | |
| 91 | + | ||
| 92 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 93 | + | ||
| 94 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.NotNil(t, result) | |
| 97 | + | ||
| 98 | + | assert.True(t, result.Added) | |
| 99 | + | assert.Equal(t, "https://example.com", result.URL) | |
| 100 | + | assert.Equal(t, "Example", result.Title) | |
| 101 | + | assert.Equal(t, "Favorite added", result.Message) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) { | |
| 105 | + | ctx := testContext() | |
| 106 | + | ||
| 107 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 108 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 109 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 110 | + | ||
| 111 | + | existingFav := &entity.Favorite{ | |
| 112 | + | ID: 42, | |
| 113 | + | URL: "https://example.com", | |
| 114 | + | Title: "Example Site", | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // URL is already a favorite | |
| 118 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |
| 119 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil) | |
| 120 | + | ||
| 121 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 122 | + | ||
| 123 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | require.NotNil(t, result) | |
| 126 | + | ||
| 127 | + | assert.False(t, result.Added) | |
| 128 | + | assert.Equal(t, "https://example.com", result.URL) | |
| 129 | + | assert.Equal(t, "Example Site", result.Title) // Uses existing title | |
| 130 | + | assert.Equal(t, "Favorite removed", result.Message) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |
| 134 | + | ctx := testContext() | |
| 135 | + | ||
| 136 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 137 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 138 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 139 | + | ||
| 140 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 141 | + | ||
| 142 | + | result, err := uc.Toggle(ctx, "", "Title") | |
| 143 | + | require.Error(t, err) | |
| 144 | + | assert.Nil(t, result) | |
| 145 | + | assert.Contains(t, err.Error(), "URL cannot be empty") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | |
| 149 | + | ctx := testContext() | |
| 150 | + | ||
| 151 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 152 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 153 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 154 | + | ||
| 155 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error")) | |
| 156 | + | ||
| 157 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 158 | + | ||
| 159 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 160 | + | require.Error(t, err) | |
| 161 | + | assert.Nil(t, result) | |
| 162 | + | assert.Contains(t, err.Error(), "failed to check existing favorite") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) { | |
| 166 | + | ctx := testContext() | |
| 167 | + | ||
| 168 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 169 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 170 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 171 | + | ||
| 172 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |
| 173 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error")) | |
| 174 | + | ||
| 175 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 176 | + | ||
| 177 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 178 | + | require.Error(t, err) | |
| 179 | + | assert.Nil(t, result) | |
| 180 | + | assert.Contains(t, err.Error(), "failed to add favorite") | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |
| 184 | + | ctx := testContext() | |
| 185 | + | ||
| 186 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 187 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 188 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 189 | + | ||
| 190 | + | existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"} | |
| 191 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |
| 192 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error")) | |
| 193 | + | ||
| 194 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 195 | + | ||
| 196 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 197 | + | require.Error(t, err) | |
| 198 | + | assert.Nil(t, result) | |
| 199 | + | assert.Contains(t, err.Error(), "failed to remove favorite") | |
| 200 | + | } | |
| @@ -75,3 +75,126 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing. | |||||
| 75 | assert.Nil(t, urls) | 75 | assert.Nil(t, urls) | ||
| 76 | assert.Contains(t, err.Error(), "failed to get favorites") | 76 | assert.Contains(t, err.Error(), "failed to get favorites") | ||
| 77 | } | 77 | } | ||
| 78 | + | ||||
| 79 | + | func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) { | |||
| 80 | + | ctx := testContext() | |||
| 81 | + | ||||
| 82 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 83 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 84 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 85 | + | ||||
| 86 | + | // URL is not a favorite | |||
| 87 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |||
| 88 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool { | |||
| 89 | + | return f.URL == "https://example.com" && f.Title == "Example" | |||
| 90 | + | })).Return(nil) | |||
| 91 | + | ||||
| 92 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 93 | + | ||||
| 94 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.NotNil(t, result) | |||
| 97 | + | ||||
| 98 | + | assert.True(t, result.Added) | |||
| 99 | + | assert.Equal(t, "https://example.com", result.URL) | |||
| 100 | + | assert.Equal(t, "Example", result.Title) | |||
| 101 | + | assert.Equal(t, "Favorite added", result.Message) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) { | |||
| 105 | + | ctx := testContext() | |||
| 106 | + | ||||
| 107 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 108 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 109 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 110 | + | ||||
| 111 | + | existingFav := &entity.Favorite{ | |||
| 112 | + | ID: 42, | |||
| 113 | + | URL: "https://example.com", | |||
| 114 | + | Title: "Example Site", | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // URL is already a favorite | |||
| 118 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |||
| 119 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil) | |||
| 120 | + | ||||
| 121 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 122 | + | ||||
| 123 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | require.NotNil(t, result) | |||
| 126 | + | ||||
| 127 | + | assert.False(t, result.Added) | |||
| 128 | + | assert.Equal(t, "https://example.com", result.URL) | |||
| 129 | + | assert.Equal(t, "Example Site", result.Title) // Uses existing title | |||
| 130 | + | assert.Equal(t, "Favorite removed", result.Message) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |||
| 134 | + | ctx := testContext() | |||
| 135 | + | ||||
| 136 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 137 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 138 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 139 | + | ||||
| 140 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 141 | + | ||||
| 142 | + | result, err := uc.Toggle(ctx, "", "Title") | |||
| 143 | + | require.Error(t, err) | |||
| 144 | + | assert.Nil(t, result) | |||
| 145 | + | assert.Contains(t, err.Error(), "URL cannot be empty") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | |||
| 149 | + | ctx := testContext() | |||
| 150 | + | ||||
| 151 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 152 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 153 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 154 | + | ||||
| 155 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error")) | |||
| 156 | + | ||||
| 157 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 158 | + | ||||
| 159 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 160 | + | require.Error(t, err) | |||
| 161 | + | assert.Nil(t, result) | |||
| 162 | + | assert.Contains(t, err.Error(), "failed to check existing favorite") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) { | |||
| 166 | + | ctx := testContext() | |||
| 167 | + | ||||
| 168 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 169 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 170 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 171 | + | ||||
| 172 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |||
| 173 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error")) | |||
| 174 | + | ||||
| 175 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 176 | + | ||||
| 177 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 178 | + | require.Error(t, err) | |||
| 179 | + | assert.Nil(t, result) | |||
| 180 | + | assert.Contains(t, err.Error(), "failed to add favorite") | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |||
| 184 | + | ctx := testContext() | |||
| 185 | + | ||||
| 186 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 187 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 188 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 189 | + | ||||
| 190 | + | existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"} | |||
| 191 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |||
| 192 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error")) | |||
| 193 | + | ||||
| 194 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 195 | + | ||||
| 196 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 197 | + | require.Error(t, err) | |||
| 198 | + | assert.Nil(t, result) | |||
| 199 | + | assert.Contains(t, err.Error(), "failed to remove favorite") | |||
| 200 | + | } | |||
M internal/application/usecase/migrate_config.go +12 -9
| @@ -126,17 +126,17 @@ func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChang | |||
| 126 | 126 | }, nil | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | // Execute adds missing default keys to the user's config file. | |
| 129 | + | // Execute adds missing default keys and removes deprecated keys from the user's config file. | |
| 130 | 130 | func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) { | |
| 131 | 131 | log := logging.FromContext(ctx) | |
| 132 | 132 | ||
| 133 | - | // First check if migration is needed | |
| 134 | - | checkResult, err := uc.migrator.CheckMigration() | |
| 133 | + | // Check for any changes (added, removed, or renamed keys) | |
| 134 | + | changes, err := uc.migrator.DetectChanges() | |
| 135 | 135 | if err != nil { | |
| 136 | 136 | return nil, err | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | - | if checkResult == nil || len(checkResult.MissingKeys) == 0 { | |
| 139 | + | if len(changes) == 0 { | |
| 140 | 140 | log.Debug().Msg("no migration needed") | |
| 141 | 141 | return &MigrateConfigOutput{ | |
| 142 | 142 | AddedKeys: nil, | |
| @@ -144,20 +144,23 @@ func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInpu | |||
| 144 | 144 | }, nil | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | + | // Get config file path for output | |
| 148 | + | configFile, _ := uc.migrator.GetConfigFile() | |
| 149 | + | ||
| 147 | 150 | // Perform migration | |
| 148 | - | addedKeys, err := uc.migrator.Migrate() | |
| 151 | + | appliedKeys, err := uc.migrator.Migrate() | |
| 149 | 152 | if err != nil { | |
| 150 | 153 | log.Error().Err(err).Msg("config migration failed") | |
| 151 | 154 | return nil, err | |
| 152 | 155 | } | |
| 153 | 156 | ||
| 154 | 157 | log.Info(). | |
| 155 | - | Int("added_keys", len(addedKeys)). | |
| 156 | - | Str("config_file", checkResult.ConfigFile). | |
| 158 | + | Int("applied_keys", len(appliedKeys)). | |
| 159 | + | Str("config_file", configFile). | |
| 157 | 160 | Msg("config migration completed") | |
| 158 | 161 | ||
| 159 | 162 | return &MigrateConfigOutput{ | |
| 160 | - | AddedKeys: addedKeys, | |
| 161 | - | ConfigFile: checkResult.ConfigFile, | |
| 163 | + | AddedKeys: appliedKeys, | |
| 164 | + | ConfigFile: configFile, | |
| 162 | 165 | }, nil | |
| 163 | 166 | } | |
| @@ -126,17 +126,17 @@ func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChang | |||||
| 126 | }, nil | 126 | }, nil | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | // Execute adds missing default keys to the user's config file. | 129 | + | // Execute adds missing default keys and removes deprecated keys from the user's config file. |
| 130 | func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) { | 130 | func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) { | ||
| 131 | log := logging.FromContext(ctx) | 131 | log := logging.FromContext(ctx) | ||
| 132 | 132 | ||||
| 133 | - | // First check if migration is needed | 133 | + | // Check for any changes (added, removed, or renamed keys) |
| 134 | - | checkResult, err := uc.migrator.CheckMigration() | 134 | + | changes, err := uc.migrator.DetectChanges() |
| 135 | if err != nil { | 135 | if err != nil { | ||
| 136 | return nil, err | 136 | return nil, err | ||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| 139 | - | if checkResult == nil || len(checkResult.MissingKeys) == 0 { | 139 | + | if len(changes) == 0 { |
| 140 | log.Debug().Msg("no migration needed") | 140 | log.Debug().Msg("no migration needed") | ||
| 141 | return &MigrateConfigOutput{ | 141 | return &MigrateConfigOutput{ | ||
| 142 | AddedKeys: nil, | 142 | AddedKeys: nil, | ||
| @@ -144,20 +144,23 @@ func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInpu | |||||
| 144 | }, nil | 144 | }, nil | ||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | + | // Get config file path for output | |||
| 148 | + | configFile, _ := uc.migrator.GetConfigFile() | |||
| 149 | + | ||||
| 147 | // Perform migration | 150 | // Perform migration | ||
| 148 | - | addedKeys, err := uc.migrator.Migrate() | 151 | + | appliedKeys, err := uc.migrator.Migrate() |
| 149 | if err != nil { | 152 | if err != nil { | ||
| 150 | log.Error().Err(err).Msg("config migration failed") | 153 | log.Error().Err(err).Msg("config migration failed") | ||
| 151 | return nil, err | 154 | return nil, err | ||
| 152 | } | 155 | } | ||
| 153 | 156 | ||||
| 154 | log.Info(). | 157 | log.Info(). | ||
| 155 | - | Int("added_keys", len(addedKeys)). | 158 | + | Int("applied_keys", len(appliedKeys)). |
| 156 | - | Str("config_file", checkResult.ConfigFile). | 159 | + | Str("config_file", configFile). |
| 157 | Msg("config migration completed") | 160 | Msg("config migration completed") | ||
| 158 | 161 | ||||
| 159 | return &MigrateConfigOutput{ | 162 | return &MigrateConfigOutput{ | ||
| 160 | - | AddedKeys: addedKeys, | 163 | + | AddedKeys: appliedKeys, |
| 161 | - | ConfigFile: checkResult.ConfigFile, | 164 | + | ConfigFile: configFile, |
| 162 | }, nil | 165 | }, nil | ||
| 163 | } | 166 | } | ||
M internal/application/usecase/migrate_config_test.go +13 -12
| @@ -86,7 +86,7 @@ func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | |||
| 86 | 86 | func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | |
| 87 | 87 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 88 | 88 | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 89 | - | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | |
| 89 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) | |
| 90 | 90 | ||
| 91 | 91 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 92 | 92 | ctx := context.Background() | |
| @@ -101,11 +101,12 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |||
| 101 | 101 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 102 | 102 | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 103 | 103 | ||
| 104 | - | migrationResult := &port.MigrationResult{ | |
| 105 | - | MissingKeys: []string{"key1", "key2"}, | |
| 106 | - | ConfigFile: "/path/to/config.toml", | |
| 104 | + | changes := []port.KeyChange{ | |
| 105 | + | {Type: port.KeyChangeAdded, NewKey: "key1"}, | |
| 106 | + | {Type: port.KeyChangeAdded, NewKey: "key2"}, | |
| 107 | 107 | } | |
| 108 | - | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | |
| 108 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 109 | + | mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil) | |
| 109 | 110 | mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil) | |
| 110 | 111 | ||
| 111 | 112 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| @@ -122,11 +123,11 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||
| 122 | 123 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 123 | 124 | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 124 | 125 | ||
| 125 | - | migrationResult := &port.MigrationResult{ | |
| 126 | - | MissingKeys: []string{"key1"}, | |
| 127 | - | ConfigFile: "/path/to/config.toml", | |
| 126 | + | changes := []port.KeyChange{ | |
| 127 | + | {Type: port.KeyChangeAdded, NewKey: "key1"}, | |
| 128 | 128 | } | |
| 129 | - | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | |
| 129 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 130 | + | mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil) | |
| 130 | 131 | ||
| 131 | 132 | expectedErr := errors.New("migrate failed") | |
| 132 | 133 | mockMigrator.EXPECT().Migrate().Return(nil, expectedErr) | |
| @@ -141,12 +142,12 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||
| 141 | 142 | assert.Equal(t, expectedErr, err) | |
| 142 | 143 | } | |
| 143 | 144 | ||
| 144 | - | func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) { | |
| 145 | + | func TestMigrateConfigUseCase_Execute_DetectChangesError(t *testing.T) { | |
| 145 | 146 | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 146 | 147 | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 147 | 148 | ||
| 148 | - | expectedErr := errors.New("check failed") | |
| 149 | - | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | |
| 149 | + | expectedErr := errors.New("detect changes failed") | |
| 150 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) | |
| 150 | 151 | ||
| 151 | 152 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 152 | 153 | ctx := context.Background() | |
| @@ -86,7 +86,7 @@ func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | |||||
| 86 | func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | 86 | func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | ||
| 87 | mockMigrator := mocks.NewMockConfigMigrator(t) | 87 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 88 | mockFormatter := mocks.NewMockDiffFormatter(t) | 88 | mockFormatter := mocks.NewMockDiffFormatter(t) | ||
| 89 | - | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | 89 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) |
| 90 | 90 | ||||
| 91 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | 91 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | ||
| 92 | ctx := context.Background() | 92 | ctx := context.Background() | ||
| @@ -101,11 +101,12 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |||||
| 101 | mockMigrator := mocks.NewMockConfigMigrator(t) | 101 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 102 | mockFormatter := mocks.NewMockDiffFormatter(t) | 102 | mockFormatter := mocks.NewMockDiffFormatter(t) | ||
| 103 | 103 | ||||
| 104 | - | migrationResult := &port.MigrationResult{ | 104 | + | changes := []port.KeyChange{ |
| 105 | - | MissingKeys: []string{"key1", "key2"}, | 105 | + | {Type: port.KeyChangeAdded, NewKey: "key1"}, |
| 106 | - | ConfigFile: "/path/to/config.toml", | 106 | + | {Type: port.KeyChangeAdded, NewKey: "key2"}, |
| 107 | } | 107 | } | ||
| 108 | - | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | 108 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) |
| 109 | + | mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil) | |||
| 109 | mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil) | 110 | mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil) | ||
| 110 | 111 | ||||
| 111 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | 112 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | ||
| @@ -122,11 +123,11 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||||
| 122 | mockMigrator := mocks.NewMockConfigMigrator(t) | 123 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 123 | mockFormatter := mocks.NewMockDiffFormatter(t) | 124 | mockFormatter := mocks.NewMockDiffFormatter(t) | ||
| 124 | 125 | ||||
| 125 | - | migrationResult := &port.MigrationResult{ | 126 | + | changes := []port.KeyChange{ |
| 126 | - | MissingKeys: []string{"key1"}, | 127 | + | {Type: port.KeyChangeAdded, NewKey: "key1"}, |
| 127 | - | ConfigFile: "/path/to/config.toml", | |||
| 128 | } | 128 | } | ||
| 129 | - | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | 129 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) |
| 130 | + | mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil) | |||
| 130 | 131 | ||||
| 131 | expectedErr := errors.New("migrate failed") | 132 | expectedErr := errors.New("migrate failed") | ||
| 132 | mockMigrator.EXPECT().Migrate().Return(nil, expectedErr) | 133 | mockMigrator.EXPECT().Migrate().Return(nil, expectedErr) | ||
| @@ -141,12 +142,12 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |||||
| 141 | assert.Equal(t, expectedErr, err) | 142 | assert.Equal(t, expectedErr, err) | ||
| 142 | } | 143 | } | ||
| 143 | 144 | ||||
| 144 | - | func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) { | 145 | + | func TestMigrateConfigUseCase_Execute_DetectChangesError(t *testing.T) { |
| 145 | mockMigrator := mocks.NewMockConfigMigrator(t) | 146 | mockMigrator := mocks.NewMockConfigMigrator(t) | ||
| 146 | mockFormatter := mocks.NewMockDiffFormatter(t) | 147 | mockFormatter := mocks.NewMockDiffFormatter(t) | ||
| 147 | 148 | ||||
| 148 | - | expectedErr := errors.New("check failed") | 149 | + | expectedErr := errors.New("detect changes failed") |
| 149 | - | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | 150 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) |
| 150 | 151 | ||||
| 151 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | 152 | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | ||
| 152 | ctx := context.Background() | 153 | ctx := context.Background() | ||
M internal/cli/cmd/config.go +107 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | + | "os/exec" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/charmbracelet/bubbles/spinner" | |
| 9 | 10 | tea "github.com/charmbracelet/bubbletea" | |
| @@ -24,6 +25,18 @@ var configCmd = &cobra.Command{ | |||
| 24 | 25 | Long: `View configuration status and migrate to add new default settings.`, | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 28 | + | var configOpenCmd = &cobra.Command{ | |
| 29 | + | Use: "open", | |
| 30 | + | Short: "Open config file in your editor", | |
| 31 | + | Long: `Opens the config file using your preferred editor. | |
| 32 | + | ||
| 33 | + | Editor selection order: | |
| 34 | + | 1. $EDITOR environment variable | |
| 35 | + | 2. $VISUAL environment variable | |
| 36 | + | 3. Fallback to: nano, vim, vi (first found)`, | |
| 37 | + | RunE: runConfigOpen, | |
| 38 | + | } | |
| 39 | + | ||
| 27 | 40 | var configStatusCmd = &cobra.Command{ | |
| 28 | 41 | Use: "status", | |
| 29 | 42 | Short: "Show config file status and migration availability", | |
| @@ -40,11 +53,105 @@ Existing settings are never modified - only missing keys are added with default | |||
| 40 | 53 | RunE: runConfigMigrate, | |
| 41 | 54 | } | |
| 42 | 55 | ||
| 56 | + | var configSchemaJSON bool | |
| 57 | + | ||
| 58 | + | var configSchemaCmd = &cobra.Command{ | |
| 59 | + | Use: "schema", | |
| 60 | + | Short: "Show all config keys with types and valid values", | |
| 61 | + | Long: `Display a reference of all configuration keys with their types, defaults, | |
| 62 | + | descriptions, and valid values. | |
| 63 | + | ||
| 64 | + | Use --json for machine-readable output.`, | |
| 65 | + | RunE: runConfigSchema, | |
| 66 | + | } | |
| 67 | + | ||
| 43 | 68 | func init() { | |
| 44 | 69 | rootCmd.AddCommand(configCmd) | |
| 70 | + | configCmd.AddCommand(configOpenCmd) | |
| 45 | 71 | configCmd.AddCommand(configStatusCmd) | |
| 46 | 72 | configCmd.AddCommand(configMigrateCmd) | |
| 73 | + | configCmd.AddCommand(configSchemaCmd) | |
| 47 | 74 | configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt") | |
| 75 | + | configSchemaCmd.Flags().BoolVar(&configSchemaJSON, "json", false, "output as JSON") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // runConfigSchema displays all configuration keys with their metadata. | |
| 79 | + | func runConfigSchema(_ *cobra.Command, _ []string) error { | |
| 80 | + | app := GetApp() | |
| 81 | + | if app == nil { | |
| 82 | + | return fmt.Errorf("app not initialized") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | provider := config.NewSchemaProvider() | |
| 86 | + | uc := usecase.NewGetConfigSchemaUseCase(provider) | |
| 87 | + | ||
| 88 | + | ctx := context.Background() | |
| 89 | + | result, err := uc.Execute(ctx, usecase.GetConfigSchemaInput{}) | |
| 90 | + | if err != nil { | |
| 91 | + | return fmt.Errorf("get config schema: %w", err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | renderer := styles.NewConfigSchemaRenderer(app.Theme) | |
| 95 | + | ||
| 96 | + | if configSchemaJSON { | |
| 97 | + | output, err := renderer.RenderJSON(result.Keys) | |
| 98 | + | if err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | fmt.Println(output) | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fmt.Println(renderer.Render(result.Keys)) | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // runConfigOpen opens the config file in the user's preferred editor. | |
| 110 | + | func runConfigOpen(_ *cobra.Command, _ []string) error { | |
| 111 | + | configFile, err := config.GetConfigFile() | |
| 112 | + | if err != nil { | |
| 113 | + | return fmt.Errorf("get config file: %w", err) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Check if config file exists | |
| 117 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 118 | + | return fmt.Errorf("config file does not exist: %s", configFile) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | editor := getEditor() | |
| 122 | + | if editor == "" { | |
| 123 | + | return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | cmd := exec.Command(editor, configFile) | |
| 127 | + | cmd.Stdin = os.Stdin | |
| 128 | + | cmd.Stdout = os.Stdout | |
| 129 | + | cmd.Stderr = os.Stderr | |
| 130 | + | ||
| 131 | + | return cmd.Run() | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // getEditor returns the user's preferred editor. | |
| 135 | + | func getEditor() string { | |
| 136 | + | // Check $EDITOR first | |
| 137 | + | if editor := os.Getenv("EDITOR"); editor != "" { | |
| 138 | + | return editor | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Then $VISUAL | |
| 142 | + | if editor := os.Getenv("VISUAL"); editor != "" { | |
| 143 | + | return editor | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Fallback to common editors | |
| 147 | + | fallbacks := []string{"nano", "vim", "vi"} | |
| 148 | + | for _, editor := range fallbacks { | |
| 149 | + | if path, err := exec.LookPath(editor); err == nil { | |
| 150 | + | return path | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return "" | |
| 48 | 155 | } | |
| 49 | 156 | ||
| 50 | 157 | // runConfigStatus shows config file path and migration status. | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | + | "os/exec" | |||
| 7 | 8 | ||||
| 8 | "github.com/charmbracelet/bubbles/spinner" | 9 | "github.com/charmbracelet/bubbles/spinner" | ||
| 9 | tea "github.com/charmbracelet/bubbletea" | 10 | tea "github.com/charmbracelet/bubbletea" | ||
| @@ -24,6 +25,18 @@ var configCmd = &cobra.Command{ | |||||
| 24 | Long: `View configuration status and migrate to add new default settings.`, | 25 | Long: `View configuration status and migrate to add new default settings.`, | ||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 28 | + | var configOpenCmd = &cobra.Command{ | |||
| 29 | + | Use: "open", | |||
| 30 | + | Short: "Open config file in your editor", | |||
| 31 | + | Long: `Opens the config file using your preferred editor. | |||
| 32 | + | ||||
| 33 | + | Editor selection order: | |||
| 34 | + | 1. $EDITOR environment variable | |||
| 35 | + | 2. $VISUAL environment variable | |||
| 36 | + | 3. Fallback to: nano, vim, vi (first found)`, | |||
| 37 | + | RunE: runConfigOpen, | |||
| 38 | + | } | |||
| 39 | + | ||||
| 27 | var configStatusCmd = &cobra.Command{ | 40 | var configStatusCmd = &cobra.Command{ | ||
| 28 | Use: "status", | 41 | Use: "status", | ||
| 29 | Short: "Show config file status and migration availability", | 42 | Short: "Show config file status and migration availability", | ||
| @@ -40,11 +53,105 @@ Existing settings are never modified - only missing keys are added with default | |||||
| 40 | RunE: runConfigMigrate, | 53 | RunE: runConfigMigrate, | ||
| 41 | } | 54 | } | ||
| 42 | 55 | ||||
| 56 | + | var configSchemaJSON bool | |||
| 57 | + | ||||
| 58 | + | var configSchemaCmd = &cobra.Command{ | |||
| 59 | + | Use: "schema", | |||
| 60 | + | Short: "Show all config keys with types and valid values", | |||
| 61 | + | Long: `Display a reference of all configuration keys with their types, defaults, | |||
| 62 | + | descriptions, and valid values. | |||
| 63 | + | ||||
| 64 | + | Use --json for machine-readable output.`, | |||
| 65 | + | RunE: runConfigSchema, | |||
| 66 | + | } | |||
| 67 | + | ||||
| 43 | func init() { | 68 | func init() { | ||
| 44 | rootCmd.AddCommand(configCmd) | 69 | rootCmd.AddCommand(configCmd) | ||
| 70 | + | configCmd.AddCommand(configOpenCmd) | |||
| 45 | configCmd.AddCommand(configStatusCmd) | 71 | configCmd.AddCommand(configStatusCmd) | ||
| 46 | configCmd.AddCommand(configMigrateCmd) | 72 | configCmd.AddCommand(configMigrateCmd) | ||
| 73 | + | configCmd.AddCommand(configSchemaCmd) | |||
| 47 | configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt") | 74 | configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt") | ||
| 75 | + | configSchemaCmd.Flags().BoolVar(&configSchemaJSON, "json", false, "output as JSON") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // runConfigSchema displays all configuration keys with their metadata. | |||
| 79 | + | func runConfigSchema(_ *cobra.Command, _ []string) error { | |||
| 80 | + | app := GetApp() | |||
| 81 | + | if app == nil { | |||
| 82 | + | return fmt.Errorf("app not initialized") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | provider := config.NewSchemaProvider() | |||
| 86 | + | uc := usecase.NewGetConfigSchemaUseCase(provider) | |||
| 87 | + | ||||
| 88 | + | ctx := context.Background() | |||
| 89 | + | result, err := uc.Execute(ctx, usecase.GetConfigSchemaInput{}) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return fmt.Errorf("get config schema: %w", err) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | renderer := styles.NewConfigSchemaRenderer(app.Theme) | |||
| 95 | + | ||||
| 96 | + | if configSchemaJSON { | |||
| 97 | + | output, err := renderer.RenderJSON(result.Keys) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | fmt.Println(output) | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | fmt.Println(renderer.Render(result.Keys)) | |||
| 106 | + | return nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // runConfigOpen opens the config file in the user's preferred editor. | |||
| 110 | + | func runConfigOpen(_ *cobra.Command, _ []string) error { | |||
| 111 | + | configFile, err := config.GetConfigFile() | |||
| 112 | + | if err != nil { | |||
| 113 | + | return fmt.Errorf("get config file: %w", err) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Check if config file exists | |||
| 117 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |||
| 118 | + | return fmt.Errorf("config file does not exist: %s", configFile) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | editor := getEditor() | |||
| 122 | + | if editor == "" { | |||
| 123 | + | return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable") | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | cmd := exec.Command(editor, configFile) | |||
| 127 | + | cmd.Stdin = os.Stdin | |||
| 128 | + | cmd.Stdout = os.Stdout | |||
| 129 | + | cmd.Stderr = os.Stderr | |||
| 130 | + | ||||
| 131 | + | return cmd.Run() | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // getEditor returns the user's preferred editor. | |||
| 135 | + | func getEditor() string { | |||
| 136 | + | // Check $EDITOR first | |||
| 137 | + | if editor := os.Getenv("EDITOR"); editor != "" { | |||
| 138 | + | return editor | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Then $VISUAL | |||
| 142 | + | if editor := os.Getenv("VISUAL"); editor != "" { | |||
| 143 | + | return editor | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Fallback to common editors | |||
| 147 | + | fallbacks := []string{"nano", "vim", "vi"} | |||
| 148 | + | for _, editor := range fallbacks { | |||
| 149 | + | if path, err := exec.LookPath(editor); err == nil { | |||
| 150 | + | return path | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | return "" | |||
| 48 | } | 155 | } | ||
| 49 | 156 | ||||
| 50 | // runConfigStatus shows config file path and migration status. | 157 | // runConfigStatus shows config file path and migration status. | ||
A internal/cli/styles/config_schema.go +144 -0
| @@ -0,0 +1,144 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ConfigSchemaRenderer renders configuration schema information. | |
| 14 | + | type ConfigSchemaRenderer struct { | |
| 15 | + | theme *Theme | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // NewConfigSchemaRenderer creates a new ConfigSchemaRenderer. | |
| 19 | + | func NewConfigSchemaRenderer(theme *Theme) *ConfigSchemaRenderer { | |
| 20 | + | return &ConfigSchemaRenderer{theme: theme} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Render renders the configuration schema in styled format. | |
| 24 | + | func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string { | |
| 25 | + | if len(keys) == 0 { | |
| 26 | + | return r.theme.Subtle.Render("No configuration keys found") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Group keys by section | |
| 30 | + | sections := groupBySection(keys) | |
| 31 | + | ||
| 32 | + | // Build output | |
| 33 | + | var parts []string | |
| 34 | + | ||
| 35 | + | // Header | |
| 36 | + | header := r.renderHeader() | |
| 37 | + | parts = append(parts, header, "") | |
| 38 | + | ||
| 39 | + | // Render each section | |
| 40 | + | sectionOrder := []string{ | |
| 41 | + | "Appearance", | |
| 42 | + | "Logging", | |
| 43 | + | "History", | |
| 44 | + | "Search", | |
| 45 | + | "Dmenu", | |
| 46 | + | "Workspace", | |
| 47 | + | "Session", | |
| 48 | + | "Omnibox", | |
| 49 | + | "Content Filtering", | |
| 50 | + | "Rendering", | |
| 51 | + | "Media", | |
| 52 | + | "Update", | |
| 53 | + | "Debug", | |
| 54 | + | "Performance", | |
| 55 | + | "Runtime", | |
| 56 | + | "Database", | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | for _, section := range sectionOrder { | |
| 60 | + | if sectionKeys, ok := sections[section]; ok { | |
| 61 | + | parts = append(parts, r.renderSection(section, sectionKeys), "") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | return strings.Join(parts, "\n") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // RenderJSON renders the configuration schema as JSON. | |
| 69 | + | func (*ConfigSchemaRenderer) RenderJSON(keys []entity.ConfigKeyInfo) (string, error) { | |
| 70 | + | data, err := json.MarshalIndent(keys, "", " ") | |
| 71 | + | if err != nil { | |
| 72 | + | return "", fmt.Errorf("marshal schema: %w", err) | |
| 73 | + | } | |
| 74 | + | return string(data), nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (r *ConfigSchemaRenderer) renderHeader() string { | |
| 78 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 79 | + | title := fmt.Sprintf("%s %s", iconStyle.Render(IconConfig), r.theme.Title.Render("Config Schema Reference")) | |
| 80 | + | return title | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func groupBySection(keys []entity.ConfigKeyInfo) map[string][]entity.ConfigKeyInfo { | |
| 84 | + | sections := make(map[string][]entity.ConfigKeyInfo) | |
| 85 | + | for _, key := range keys { | |
| 86 | + | sections[key.Section] = append(sections[key.Section], key) | |
| 87 | + | } | |
| 88 | + | return sections | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (r *ConfigSchemaRenderer) renderSection(name string, keys []entity.ConfigKeyInfo) string { | |
| 92 | + | var lines []string | |
| 93 | + | ||
| 94 | + | for _, key := range keys { | |
| 95 | + | lines = append(lines, r.renderKey(key)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | body := strings.Join(lines, "\n") | |
| 99 | + | ||
| 100 | + | // Create section header (no extra padding/margin) | |
| 101 | + | sectionHeader := r.theme.Highlight.Render(name) | |
| 102 | + | ||
| 103 | + | // Build box content: header directly followed by keys | |
| 104 | + | boxContent := sectionHeader + "\n" + body | |
| 105 | + | ||
| 106 | + | // Use box style without top padding | |
| 107 | + | boxStyle := r.theme.Box.PaddingTop(0) | |
| 108 | + | ||
| 109 | + | return boxStyle.Render(boxContent) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (r *ConfigSchemaRenderer) renderKey(key entity.ConfigKeyInfo) string { | |
| 113 | + | keyStyle := r.theme.Normal.Bold(true) | |
| 114 | + | typeStyle := r.theme.Subtle | |
| 115 | + | defaultStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 116 | + | descStyle := r.theme.Subtle | |
| 117 | + | valuesStyle := r.theme.Normal | |
| 118 | + | ||
| 119 | + | // Line 1: key name, type, default | |
| 120 | + | line1 := fmt.Sprintf( | |
| 121 | + | "%s %s %s", | |
| 122 | + | keyStyle.Render(key.Key), | |
| 123 | + | typeStyle.Render(key.Type), | |
| 124 | + | defaultStyle.Render(key.Default), | |
| 125 | + | ) | |
| 126 | + | ||
| 127 | + | // Line 2: description (indented) | |
| 128 | + | line2 := fmt.Sprintf(" %s", descStyle.Render(key.Description)) | |
| 129 | + | ||
| 130 | + | result := line1 + "\n" + line2 | |
| 131 | + | ||
| 132 | + | // Line 3 (optional): values or range | |
| 133 | + | if len(key.Values) > 0 { | |
| 134 | + | valuesText := "Values: " + strings.Join(key.Values, ", ") | |
| 135 | + | line3 := fmt.Sprintf(" %s", valuesStyle.Render(valuesText)) | |
| 136 | + | result += "\n" + line3 | |
| 137 | + | } else if key.Range != "" { | |
| 138 | + | rangeText := "Range: " + key.Range | |
| 139 | + | line3 := fmt.Sprintf(" %s", valuesStyle.Render(rangeText)) | |
| 140 | + | result += "\n" + line3 | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | return result | |
| 144 | + | } | |
| @@ -0,0 +1,144 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/charmbracelet/lipgloss" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ConfigSchemaRenderer renders configuration schema information. | |||
| 14 | + | type ConfigSchemaRenderer struct { | |||
| 15 | + | theme *Theme | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // NewConfigSchemaRenderer creates a new ConfigSchemaRenderer. | |||
| 19 | + | func NewConfigSchemaRenderer(theme *Theme) *ConfigSchemaRenderer { | |||
| 20 | + | return &ConfigSchemaRenderer{theme: theme} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Render renders the configuration schema in styled format. | |||
| 24 | + | func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string { | |||
| 25 | + | if len(keys) == 0 { | |||
| 26 | + | return r.theme.Subtle.Render("No configuration keys found") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Group keys by section | |||
| 30 | + | sections := groupBySection(keys) | |||
| 31 | + | ||||
| 32 | + | // Build output | |||
| 33 | + | var parts []string | |||
| 34 | + | ||||
| 35 | + | // Header | |||
| 36 | + | header := r.renderHeader() | |||
| 37 | + | parts = append(parts, header, "") | |||
| 38 | + | ||||
| 39 | + | // Render each section | |||
| 40 | + | sectionOrder := []string{ | |||
| 41 | + | "Appearance", | |||
| 42 | + | "Logging", | |||
| 43 | + | "History", | |||
| 44 | + | "Search", | |||
| 45 | + | "Dmenu", | |||
| 46 | + | "Workspace", | |||
| 47 | + | "Session", | |||
| 48 | + | "Omnibox", | |||
| 49 | + | "Content Filtering", | |||
| 50 | + | "Rendering", | |||
| 51 | + | "Media", | |||
| 52 | + | "Update", | |||
| 53 | + | "Debug", | |||
| 54 | + | "Performance", | |||
| 55 | + | "Runtime", | |||
| 56 | + | "Database", | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | for _, section := range sectionOrder { | |||
| 60 | + | if sectionKeys, ok := sections[section]; ok { | |||
| 61 | + | parts = append(parts, r.renderSection(section, sectionKeys), "") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | return strings.Join(parts, "\n") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // RenderJSON renders the configuration schema as JSON. | |||
| 69 | + | func (*ConfigSchemaRenderer) RenderJSON(keys []entity.ConfigKeyInfo) (string, error) { | |||
| 70 | + | data, err := json.MarshalIndent(keys, "", " ") | |||
| 71 | + | if err != nil { | |||
| 72 | + | return "", fmt.Errorf("marshal schema: %w", err) | |||
| 73 | + | } | |||
| 74 | + | return string(data), nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (r *ConfigSchemaRenderer) renderHeader() string { | |||
| 78 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 79 | + | title := fmt.Sprintf("%s %s", iconStyle.Render(IconConfig), r.theme.Title.Render("Config Schema Reference")) | |||
| 80 | + | return title | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func groupBySection(keys []entity.ConfigKeyInfo) map[string][]entity.ConfigKeyInfo { | |||
| 84 | + | sections := make(map[string][]entity.ConfigKeyInfo) | |||
| 85 | + | for _, key := range keys { | |||
| 86 | + | sections[key.Section] = append(sections[key.Section], key) | |||
| 87 | + | } | |||
| 88 | + | return sections | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (r *ConfigSchemaRenderer) renderSection(name string, keys []entity.ConfigKeyInfo) string { | |||
| 92 | + | var lines []string | |||
| 93 | + | ||||
| 94 | + | for _, key := range keys { | |||
| 95 | + | lines = append(lines, r.renderKey(key)) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | body := strings.Join(lines, "\n") | |||
| 99 | + | ||||
| 100 | + | // Create section header (no extra padding/margin) | |||
| 101 | + | sectionHeader := r.theme.Highlight.Render(name) | |||
| 102 | + | ||||
| 103 | + | // Build box content: header directly followed by keys | |||
| 104 | + | boxContent := sectionHeader + "\n" + body | |||
| 105 | + | ||||
| 106 | + | // Use box style without top padding | |||
| 107 | + | boxStyle := r.theme.Box.PaddingTop(0) | |||
| 108 | + | ||||
| 109 | + | return boxStyle.Render(boxContent) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (r *ConfigSchemaRenderer) renderKey(key entity.ConfigKeyInfo) string { | |||
| 113 | + | keyStyle := r.theme.Normal.Bold(true) | |||
| 114 | + | typeStyle := r.theme.Subtle | |||
| 115 | + | defaultStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 116 | + | descStyle := r.theme.Subtle | |||
| 117 | + | valuesStyle := r.theme.Normal | |||
| 118 | + | ||||
| 119 | + | // Line 1: key name, type, default | |||
| 120 | + | line1 := fmt.Sprintf( | |||
| 121 | + | "%s %s %s", | |||
| 122 | + | keyStyle.Render(key.Key), | |||
| 123 | + | typeStyle.Render(key.Type), | |||
| 124 | + | defaultStyle.Render(key.Default), | |||
| 125 | + | ) | |||
| 126 | + | ||||
| 127 | + | // Line 2: description (indented) | |||
| 128 | + | line2 := fmt.Sprintf(" %s", descStyle.Render(key.Description)) | |||
| 129 | + | ||||
| 130 | + | result := line1 + "\n" + line2 | |||
| 131 | + | ||||
| 132 | + | // Line 3 (optional): values or range | |||
| 133 | + | if len(key.Values) > 0 { | |||
| 134 | + | valuesText := "Values: " + strings.Join(key.Values, ", ") | |||
| 135 | + | line3 := fmt.Sprintf(" %s", valuesStyle.Render(valuesText)) | |||
| 136 | + | result += "\n" + line3 | |||
| 137 | + | } else if key.Range != "" { | |||
| 138 | + | rangeText := "Range: " + key.Range | |||
| 139 | + | line3 := fmt.Sprintf(" %s", valuesStyle.Render(rangeText)) | |||
| 140 | + | result += "\n" + line3 | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | return result | |||
| 144 | + | } | |||
A internal/domain/entity/config_key.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | // ConfigKeyInfo describes a single configuration key for schema documentation. | |
| 4 | + | type ConfigKeyInfo struct { | |
| 5 | + | // Key is the full dotted path to the config key (e.g., "appearance.color_scheme") | |
| 6 | + | Key string `json:"key"` | |
| 7 | + | ||
| 8 | + | // Type is the Go type name (e.g., "string", "int", "bool", "float64") | |
| 9 | + | Type string `json:"type"` | |
| 10 | + | ||
| 11 | + | // Default is the default value as a string representation | |
| 12 | + | Default string `json:"default"` | |
| 13 | + | ||
| 14 | + | // Description explains the purpose of this config key | |
| 15 | + | Description string `json:"description"` | |
| 16 | + | ||
| 17 | + | // Values contains valid enum values (for string enums) | |
| 18 | + | // Empty if not an enum type | |
| 19 | + | Values []string `json:"values,omitempty"` | |
| 20 | + | ||
| 21 | + | // Range describes numeric constraints (e.g., "1-72", "0.1-5.0") | |
| 22 | + | // Empty if no range constraint | |
| 23 | + | Range string `json:"range,omitempty"` | |
| 24 | + | ||
| 25 | + | // Section groups related keys (e.g., "Appearance", "Logging") | |
| 26 | + | Section string `json:"section"` | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | // ConfigKeyInfo describes a single configuration key for schema documentation. | |||
| 4 | + | type ConfigKeyInfo struct { | |||
| 5 | + | // Key is the full dotted path to the config key (e.g., "appearance.color_scheme") | |||
| 6 | + | Key string `json:"key"` | |||
| 7 | + | ||||
| 8 | + | // Type is the Go type name (e.g., "string", "int", "bool", "float64") | |||
| 9 | + | Type string `json:"type"` | |||
| 10 | + | ||||
| 11 | + | // Default is the default value as a string representation | |||
| 12 | + | Default string `json:"default"` | |||
| 13 | + | ||||
| 14 | + | // Description explains the purpose of this config key | |||
| 15 | + | Description string `json:"description"` | |||
| 16 | + | ||||
| 17 | + | // Values contains valid enum values (for string enums) | |||
| 18 | + | // Empty if not an enum type | |||
| 19 | + | Values []string `json:"values,omitempty"` | |||
| 20 | + | ||||
| 21 | + | // Range describes numeric constraints (e.g., "1-72", "0.1-5.0") | |||
| 22 | + | // Empty if no range constraint | |||
| 23 | + | Range string `json:"range,omitempty"` | |||
| 24 | + | ||||
| 25 | + | // Section groups related keys (e.g., "Appearance", "Logging") | |||
| 26 | + | Section string `json:"section"` | |||
| 27 | + | } | |||
M internal/infrastructure/config/migrate.go +197 -21
| @@ -1,14 +1,17 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/json" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "os" | |
| 7 | + | "path/filepath" | |
| 6 | 8 | "reflect" | |
| 7 | 9 | "sort" | |
| 8 | 10 | "strings" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/pelletier/go-toml/v2" | |
| 11 | 13 | "github.com/spf13/viper" | |
| 14 | + | "gopkg.in/yaml.v3" | |
| 12 | 15 | ||
| 13 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| 14 | 17 | ) | |
| @@ -252,6 +255,13 @@ const ( | |||
| 252 | 255 | typeNameString = "string" | |
| 253 | 256 | ) | |
| 254 | 257 | ||
| 258 | + | // Config format constants. | |
| 259 | + | const ( | |
| 260 | + | configFormatTOML = "toml" | |
| 261 | + | configFormatYAML = "yaml" | |
| 262 | + | configFormatJSON = "json" | |
| 263 | + | ) | |
| 264 | + | ||
| 255 | 265 | // typesAreCompatible checks if the default types for two keys are compatible. | |
| 256 | 266 | // This prevents matching int keys with string keys during rename detection. | |
| 257 | 267 | func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { | |
| @@ -339,7 +349,7 @@ func keysAreSimilar(oldKey, newKey string) bool { | |||
| 339 | 349 | return matches >= minTokens-1 && matches > 0 | |
| 340 | 350 | } | |
| 341 | 351 | ||
| 342 | - | // Migrate adds missing default keys to the user's config file. | |
| 352 | + | // Migrate adds missing default keys to the user's config file and removes deprecated keys. | |
| 343 | 353 | func (m *Migrator) Migrate() ([]string, error) { | |
| 344 | 354 | changes, err := m.DetectChanges() | |
| 345 | 355 | if err != nil { | |
| @@ -355,39 +365,57 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 355 | 365 | return nil, fmt.Errorf("failed to get config file path: %w", err) | |
| 356 | 366 | } | |
| 357 | 367 | ||
| 358 | - | // Create a new Viper instance to read user config | |
| 359 | - | userViper := viper.New() | |
| 360 | - | userViper.SetConfigFile(configFile) | |
| 361 | - | userViper.SetConfigType("toml") | |
| 362 | - | ||
| 363 | - | // Set all defaults first | |
| 364 | - | mgr := &Manager{viper: userViper} | |
| 365 | - | mgr.setDefaults() | |
| 366 | - | ||
| 367 | - | // Read existing config (this merges with defaults) | |
| 368 | - | if err := userViper.ReadInConfig(); err != nil { | |
| 368 | + | // Read the raw config file into a nested map | |
| 369 | + | rawConfig, err := m.readRawConfig(configFile) | |
| 370 | + | if err != nil { | |
| 369 | 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| 370 | 372 | } | |
| 371 | 373 | ||
| 372 | - | // Apply changes | |
| 374 | + | // Build sets of keys to remove and renames to apply | |
| 375 | + | keysToRemove := make(map[string]bool) | |
| 376 | + | renames := make(map[string]string) // oldKey -> newKey | |
| 377 | + | ||
| 373 | 378 | var appliedKeys []string | |
| 374 | 379 | for _, change := range changes { | |
| 375 | 380 | switch change.Type { | |
| 376 | 381 | 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 | + | renames[change.OldKey] = change.NewKey | |
| 383 | + | keysToRemove[change.OldKey] = true | |
| 384 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) | |
| 382 | 385 | case port.KeyChangeAdded: | |
| 383 | - | // New key is already set via defaults, just track it | |
| 384 | 386 | appliedKeys = append(appliedKeys, change.NewKey) | |
| 385 | 387 | 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 | + | keysToRemove[change.OldKey] = true | |
| 389 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey)) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // Apply renames: copy value from old key to new key before removing old key | |
| 394 | + | for oldKey, newKey := range renames { | |
| 395 | + | if value := m.getNestedValue(rawConfig, oldKey); value != nil { | |
| 396 | + | m.setNestedValue(rawConfig, newKey, value) | |
| 388 | 397 | } | |
| 389 | 398 | } | |
| 390 | 399 | ||
| 400 | + | // Remove deprecated and renamed keys from the raw config | |
| 401 | + | for key := range keysToRemove { | |
| 402 | + | m.deleteNestedKey(rawConfig, key) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // Create a new Viper instance with defaults for added keys | |
| 406 | + | userViper := viper.New() | |
| 407 | + | userViper.SetConfigFile(configFile) | |
| 408 | + | userViper.SetConfigType(m.getConfigType(configFile)) | |
| 409 | + | ||
| 410 | + | // Set all defaults first | |
| 411 | + | mgr := &Manager{viper: userViper} | |
| 412 | + | mgr.setDefaults() | |
| 413 | + | ||
| 414 | + | // Merge the cleaned raw config on top of defaults | |
| 415 | + | if err := userViper.MergeConfigMap(rawConfig); err != nil { | |
| 416 | + | return nil, fmt.Errorf("failed to merge config: %w", err) | |
| 417 | + | } | |
| 418 | + | ||
| 391 | 419 | // Write the merged config back | |
| 392 | 420 | if err := userViper.WriteConfig(); err != nil { | |
| 393 | 421 | return nil, fmt.Errorf("failed to write config file: %w", err) | |
| @@ -396,6 +424,149 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 396 | 424 | return appliedKeys, nil | |
| 397 | 425 | } | |
| 398 | 426 | ||
| 427 | + | // getConfigType returns the config type based on file extension. | |
| 428 | + | func (*Migrator) getConfigType(configFile string) string { | |
| 429 | + | ext := strings.TrimPrefix(filepath.Ext(configFile), ".") | |
| 430 | + | switch ext { | |
| 431 | + | case configFormatYAML, "yml": | |
| 432 | + | return configFormatYAML | |
| 433 | + | case configFormatJSON: | |
| 434 | + | return configFormatJSON | |
| 435 | + | case configFormatTOML: | |
| 436 | + | return configFormatTOML | |
| 437 | + | default: | |
| 438 | + | return configFormatTOML // default to TOML | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // readRawConfig reads the config file into a nested map without applying defaults. | |
| 443 | + | func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) { | |
| 444 | + | data, err := os.ReadFile(configFile) | |
| 445 | + | if err != nil { | |
| 446 | + | return nil, err | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | var rawConfig map[string]any | |
| 450 | + | configType := m.getConfigType(configFile) | |
| 451 | + | ||
| 452 | + | switch configType { | |
| 453 | + | case configFormatTOML: | |
| 454 | + | if err := toml.Unmarshal(data, &rawConfig); err != nil { | |
| 455 | + | return nil, fmt.Errorf("failed to parse TOML: %w", err) | |
| 456 | + | } | |
| 457 | + | case configFormatYAML: | |
| 458 | + | if err := yaml.Unmarshal(data, &rawConfig); err != nil { | |
| 459 | + | return nil, fmt.Errorf("failed to parse YAML: %w", err) | |
| 460 | + | } | |
| 461 | + | case configFormatJSON: | |
| 462 | + | if err := json.Unmarshal(data, &rawConfig); err != nil { | |
| 463 | + | return nil, fmt.Errorf("failed to parse JSON: %w", err) | |
| 464 | + | } | |
| 465 | + | default: | |
| 466 | + | return nil, fmt.Errorf("unsupported config type: %s", configType) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | return rawConfig, nil | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // getNestedValue retrieves a value from a nested map using dot-notation key. | |
| 473 | + | func (*Migrator) getNestedValue(m map[string]any, key string) any { | |
| 474 | + | parts := strings.Split(key, ".") | |
| 475 | + | current := m | |
| 476 | + | ||
| 477 | + | for i, part := range parts { | |
| 478 | + | val, ok := current[part] | |
| 479 | + | if !ok { | |
| 480 | + | return nil | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | if i == len(parts)-1 { | |
| 484 | + | return val | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | nested, ok := val.(map[string]any) | |
| 488 | + | if !ok { | |
| 489 | + | return nil | |
| 490 | + | } | |
| 491 | + | current = nested | |
| 492 | + | } | |
| 493 | + | return nil | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // setNestedValue sets a value in a nested map using dot-notation key. | |
| 497 | + | func (*Migrator) setNestedValue(m map[string]any, key string, value any) { | |
| 498 | + | parts := strings.Split(key, ".") | |
| 499 | + | current := m | |
| 500 | + | ||
| 501 | + | for i, part := range parts { | |
| 502 | + | if i == len(parts)-1 { | |
| 503 | + | current[part] = value | |
| 504 | + | return | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | val, ok := current[part] | |
| 508 | + | if !ok { | |
| 509 | + | // Create intermediate map | |
| 510 | + | newMap := make(map[string]any) | |
| 511 | + | current[part] = newMap | |
| 512 | + | current = newMap | |
| 513 | + | continue | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | nested, ok := val.(map[string]any) | |
| 517 | + | if !ok { | |
| 518 | + | // Overwrite non-map value with a map | |
| 519 | + | newMap := make(map[string]any) | |
| 520 | + | current[part] = newMap | |
| 521 | + | current = newMap | |
| 522 | + | continue | |
| 523 | + | } | |
| 524 | + | current = nested | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // deleteNestedKey removes a key from a nested map using dot-notation key. | |
| 529 | + | // It also cleans up empty parent maps. | |
| 530 | + | func (m *Migrator) deleteNestedKey(config map[string]any, key string) { | |
| 531 | + | parts := strings.Split(key, ".") | |
| 532 | + | m.deleteNestedKeyRecursive(config, parts) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | // deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents. | |
| 536 | + | func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool { | |
| 537 | + | if len(parts) == 0 { | |
| 538 | + | return false | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | part := parts[0] | |
| 542 | + | val, ok := current[part] | |
| 543 | + | if !ok { | |
| 544 | + | return false | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | if len(parts) == 1 { | |
| 548 | + | // This is the leaf key, delete it | |
| 549 | + | delete(current, part) | |
| 550 | + | return true | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | // Navigate deeper | |
| 554 | + | nested, ok := val.(map[string]any) | |
| 555 | + | if !ok { | |
| 556 | + | return false | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | // Recursively delete in the nested map | |
| 560 | + | deleted := m.deleteNestedKeyRecursive(nested, parts[1:]) | |
| 561 | + | ||
| 562 | + | // If the nested map is now empty, remove it too | |
| 563 | + | if deleted && len(nested) == 0 { | |
| 564 | + | delete(current, part) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | return deleted | |
| 568 | + | } | |
| 569 | + | ||
| 399 | 570 | // GetKeyInfo returns detailed information about a config key. | |
| 400 | 571 | func (m *Migrator) GetKeyInfo(key string) port.KeyInfo { | |
| 401 | 572 | value := m.defaultViper.Get(key) | |
| @@ -414,6 +585,11 @@ func (m *Migrator) GetKeyInfo(key string) port.KeyInfo { | |||
| 414 | 585 | } | |
| 415 | 586 | } | |
| 416 | 587 | ||
| 588 | + | // GetConfigFile returns the path to the user's config file. | |
| 589 | + | func (*Migrator) GetConfigFile() (string, error) { | |
| 590 | + | return GetConfigFile() | |
| 591 | + | } | |
| 592 | + | ||
| 417 | 593 | // getAllDefaultKeys returns all keys from the default configuration. | |
| 418 | 594 | func (m *Migrator) getAllDefaultKeys() []string { | |
| 419 | 595 | keys := m.defaultViper.AllKeys() | |
| @@ -1,14 +1,17 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/json" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "os" | 6 | "os" | ||
| 7 | + | "path/filepath" | |||
| 6 | "reflect" | 8 | "reflect" | ||
| 7 | "sort" | 9 | "sort" | ||
| 8 | "strings" | 10 | "strings" | ||
| 9 | 11 | ||||
| 10 | "github.com/pelletier/go-toml/v2" | 12 | "github.com/pelletier/go-toml/v2" | ||
| 11 | "github.com/spf13/viper" | 13 | "github.com/spf13/viper" | ||
| 14 | + | "gopkg.in/yaml.v3" | |||
| 12 | 15 | ||||
| 13 | "github.com/bnema/dumber/internal/application/port" | 16 | "github.com/bnema/dumber/internal/application/port" | ||
| 14 | ) | 17 | ) | ||
| @@ -252,6 +255,13 @@ const ( | |||||
| 252 | typeNameString = "string" | 255 | typeNameString = "string" | ||
| 253 | ) | 256 | ) | ||
| 254 | 257 | ||||
| 258 | + | // Config format constants. | |||
| 259 | + | const ( | |||
| 260 | + | configFormatTOML = "toml" | |||
| 261 | + | configFormatYAML = "yaml" | |||
| 262 | + | configFormatJSON = "json" | |||
| 263 | + | ) | |||
| 264 | + | ||||
| 255 | // typesAreCompatible checks if the default types for two keys are compatible. | 265 | // typesAreCompatible checks if the default types for two keys are compatible. | ||
| 256 | // This prevents matching int keys with string keys during rename detection. | 266 | // This prevents matching int keys with string keys during rename detection. | ||
| 257 | func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { | 267 | func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { | ||
| @@ -339,7 +349,7 @@ func keysAreSimilar(oldKey, newKey string) bool { | |||||
| 339 | return matches >= minTokens-1 && matches > 0 | 349 | return matches >= minTokens-1 && matches > 0 | ||
| 340 | } | 350 | } | ||
| 341 | 351 | ||||
| 342 | - | // Migrate adds missing default keys to the user's config file. | 352 | + | // Migrate adds missing default keys to the user's config file and removes deprecated keys. |
| 343 | func (m *Migrator) Migrate() ([]string, error) { | 353 | func (m *Migrator) Migrate() ([]string, error) { | ||
| 344 | changes, err := m.DetectChanges() | 354 | changes, err := m.DetectChanges() | ||
| 345 | if err != nil { | 355 | if err != nil { | ||
| @@ -355,39 +365,57 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 355 | return nil, fmt.Errorf("failed to get config file path: %w", err) | 365 | return nil, fmt.Errorf("failed to get config file path: %w", err) | ||
| 356 | } | 366 | } | ||
| 357 | 367 | ||||
| 358 | - | // Create a new Viper instance to read user config | 368 | + | // Read the raw config file into a nested map |
| 359 | - | userViper := viper.New() | 369 | + | rawConfig, err := m.readRawConfig(configFile) |
| 360 | - | userViper.SetConfigFile(configFile) | 370 | + | if err != nil { |
| 361 | - | userViper.SetConfigType("toml") | |||
| 362 | - | ||||
| 363 | - | // Set all defaults first | |||
| 364 | - | mgr := &Manager{viper: userViper} | |||
| 365 | - | mgr.setDefaults() | |||
| 366 | - | ||||
| 367 | - | // Read existing config (this merges with defaults) | |||
| 368 | - | if err := userViper.ReadInConfig(); err != nil { | |||
| 369 | return nil, fmt.Errorf("failed to read config file: %w", err) | 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| 370 | } | 372 | } | ||
| 371 | 373 | ||||
| 372 | - | // Apply changes | 374 | + | // Build sets of keys to remove and renames to apply |
| 375 | + | keysToRemove := make(map[string]bool) | |||
| 376 | + | renames := make(map[string]string) // oldKey -> newKey | |||
| 377 | + | ||||
| 373 | var appliedKeys []string | 378 | var appliedKeys []string | ||
| 374 | for _, change := range changes { | 379 | for _, change := range changes { | ||
| 375 | switch change.Type { | 380 | switch change.Type { | ||
| 376 | case port.KeyChangeRenamed: | 381 | case port.KeyChangeRenamed: | ||
| 377 | - | // Copy value from old key to new key | 382 | + | renames[change.OldKey] = change.NewKey |
| 378 | - | if userViper.IsSet(change.OldKey) { | 383 | + | keysToRemove[change.OldKey] = true |
| 379 | - | userViper.Set(change.NewKey, userViper.Get(change.OldKey)) | 384 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) |
| 380 | - | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) | |||
| 381 | - | } | |||
| 382 | case port.KeyChangeAdded: | 385 | case port.KeyChangeAdded: | ||
| 383 | - | // New key is already set via defaults, just track it | |||
| 384 | appliedKeys = append(appliedKeys, change.NewKey) | 386 | appliedKeys = append(appliedKeys, change.NewKey) | ||
| 385 | case port.KeyChangeRemoved: | 387 | case port.KeyChangeRemoved: | ||
| 386 | - | // Nothing to do - old key will be preserved but not used | 388 | + | keysToRemove[change.OldKey] = true |
| 387 | - | appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey)) | 389 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey)) |
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | // Apply renames: copy value from old key to new key before removing old key | |||
| 394 | + | for oldKey, newKey := range renames { | |||
| 395 | + | if value := m.getNestedValue(rawConfig, oldKey); value != nil { | |||
| 396 | + | m.setNestedValue(rawConfig, newKey, value) | |||
| 388 | } | 397 | } | ||
| 389 | } | 398 | } | ||
| 390 | 399 | ||||
| 400 | + | // Remove deprecated and renamed keys from the raw config | |||
| 401 | + | for key := range keysToRemove { | |||
| 402 | + | m.deleteNestedKey(rawConfig, key) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // Create a new Viper instance with defaults for added keys | |||
| 406 | + | userViper := viper.New() | |||
| 407 | + | userViper.SetConfigFile(configFile) | |||
| 408 | + | userViper.SetConfigType(m.getConfigType(configFile)) | |||
| 409 | + | ||||
| 410 | + | // Set all defaults first | |||
| 411 | + | mgr := &Manager{viper: userViper} | |||
| 412 | + | mgr.setDefaults() | |||
| 413 | + | ||||
| 414 | + | // Merge the cleaned raw config on top of defaults | |||
| 415 | + | if err := userViper.MergeConfigMap(rawConfig); err != nil { | |||
| 416 | + | return nil, fmt.Errorf("failed to merge config: %w", err) | |||
| 417 | + | } | |||
| 418 | + | ||||
| 391 | // Write the merged config back | 419 | // Write the merged config back | ||
| 392 | if err := userViper.WriteConfig(); err != nil { | 420 | if err := userViper.WriteConfig(); err != nil { | ||
| 393 | return nil, fmt.Errorf("failed to write config file: %w", err) | 421 | return nil, fmt.Errorf("failed to write config file: %w", err) | ||
| @@ -396,6 +424,149 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 396 | return appliedKeys, nil | 424 | return appliedKeys, nil | ||
| 397 | } | 425 | } | ||
| 398 | 426 | ||||
| 427 | + | // getConfigType returns the config type based on file extension. | |||
| 428 | + | func (*Migrator) getConfigType(configFile string) string { | |||
| 429 | + | ext := strings.TrimPrefix(filepath.Ext(configFile), ".") | |||
| 430 | + | switch ext { | |||
| 431 | + | case configFormatYAML, "yml": | |||
| 432 | + | return configFormatYAML | |||
| 433 | + | case configFormatJSON: | |||
| 434 | + | return configFormatJSON | |||
| 435 | + | case configFormatTOML: | |||
| 436 | + | return configFormatTOML | |||
| 437 | + | default: | |||
| 438 | + | return configFormatTOML // default to TOML | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // readRawConfig reads the config file into a nested map without applying defaults. | |||
| 443 | + | func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) { | |||
| 444 | + | data, err := os.ReadFile(configFile) | |||
| 445 | + | if err != nil { | |||
| 446 | + | return nil, err | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | var rawConfig map[string]any | |||
| 450 | + | configType := m.getConfigType(configFile) | |||
| 451 | + | ||||
| 452 | + | switch configType { | |||
| 453 | + | case configFormatTOML: | |||
| 454 | + | if err := toml.Unmarshal(data, &rawConfig); err != nil { | |||
| 455 | + | return nil, fmt.Errorf("failed to parse TOML: %w", err) | |||
| 456 | + | } | |||
| 457 | + | case configFormatYAML: | |||
| 458 | + | if err := yaml.Unmarshal(data, &rawConfig); err != nil { | |||
| 459 | + | return nil, fmt.Errorf("failed to parse YAML: %w", err) | |||
| 460 | + | } | |||
| 461 | + | case configFormatJSON: | |||
| 462 | + | if err := json.Unmarshal(data, &rawConfig); err != nil { | |||
| 463 | + | return nil, fmt.Errorf("failed to parse JSON: %w", err) | |||
| 464 | + | } | |||
| 465 | + | default: | |||
| 466 | + | return nil, fmt.Errorf("unsupported config type: %s", configType) | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | return rawConfig, nil | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | // getNestedValue retrieves a value from a nested map using dot-notation key. | |||
| 473 | + | func (*Migrator) getNestedValue(m map[string]any, key string) any { | |||
| 474 | + | parts := strings.Split(key, ".") | |||
| 475 | + | current := m | |||
| 476 | + | ||||
| 477 | + | for i, part := range parts { | |||
| 478 | + | val, ok := current[part] | |||
| 479 | + | if !ok { | |||
| 480 | + | return nil | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | if i == len(parts)-1 { | |||
| 484 | + | return val | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | nested, ok := val.(map[string]any) | |||
| 488 | + | if !ok { | |||
| 489 | + | return nil | |||
| 490 | + | } | |||
| 491 | + | current = nested | |||
| 492 | + | } | |||
| 493 | + | return nil | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // setNestedValue sets a value in a nested map using dot-notation key. | |||
| 497 | + | func (*Migrator) setNestedValue(m map[string]any, key string, value any) { | |||
| 498 | + | parts := strings.Split(key, ".") | |||
| 499 | + | current := m | |||
| 500 | + | ||||
| 501 | + | for i, part := range parts { | |||
| 502 | + | if i == len(parts)-1 { | |||
| 503 | + | current[part] = value | |||
| 504 | + | return | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | val, ok := current[part] | |||
| 508 | + | if !ok { | |||
| 509 | + | // Create intermediate map | |||
| 510 | + | newMap := make(map[string]any) | |||
| 511 | + | current[part] = newMap | |||
| 512 | + | current = newMap | |||
| 513 | + | continue | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | nested, ok := val.(map[string]any) | |||
| 517 | + | if !ok { | |||
| 518 | + | // Overwrite non-map value with a map | |||
| 519 | + | newMap := make(map[string]any) | |||
| 520 | + | current[part] = newMap | |||
| 521 | + | current = newMap | |||
| 522 | + | continue | |||
| 523 | + | } | |||
| 524 | + | current = nested | |||
| 525 | + | } | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | // deleteNestedKey removes a key from a nested map using dot-notation key. | |||
| 529 | + | // It also cleans up empty parent maps. | |||
| 530 | + | func (m *Migrator) deleteNestedKey(config map[string]any, key string) { | |||
| 531 | + | parts := strings.Split(key, ".") | |||
| 532 | + | m.deleteNestedKeyRecursive(config, parts) | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | // deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents. | |||
| 536 | + | func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool { | |||
| 537 | + | if len(parts) == 0 { | |||
| 538 | + | return false | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | part := parts[0] | |||
| 542 | + | val, ok := current[part] | |||
| 543 | + | if !ok { | |||
| 544 | + | return false | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | if len(parts) == 1 { | |||
| 548 | + | // This is the leaf key, delete it | |||
| 549 | + | delete(current, part) | |||
| 550 | + | return true | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | // Navigate deeper | |||
| 554 | + | nested, ok := val.(map[string]any) | |||
| 555 | + | if !ok { | |||
| 556 | + | return false | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | // Recursively delete in the nested map | |||
| 560 | + | deleted := m.deleteNestedKeyRecursive(nested, parts[1:]) | |||
| 561 | + | ||||
| 562 | + | // If the nested map is now empty, remove it too | |||
| 563 | + | if deleted && len(nested) == 0 { | |||
| 564 | + | delete(current, part) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | return deleted | |||
| 568 | + | } | |||
| 569 | + | ||||
| 399 | // GetKeyInfo returns detailed information about a config key. | 570 | // GetKeyInfo returns detailed information about a config key. | ||
| 400 | func (m *Migrator) GetKeyInfo(key string) port.KeyInfo { | 571 | func (m *Migrator) GetKeyInfo(key string) port.KeyInfo { | ||
| 401 | value := m.defaultViper.Get(key) | 572 | value := m.defaultViper.Get(key) | ||
| @@ -414,6 +585,11 @@ func (m *Migrator) GetKeyInfo(key string) port.KeyInfo { | |||||
| 414 | } | 585 | } | ||
| 415 | } | 586 | } | ||
| 416 | 587 | ||||
| 588 | + | // GetConfigFile returns the path to the user's config file. | |||
| 589 | + | func (*Migrator) GetConfigFile() (string, error) { | |||
| 590 | + | return GetConfigFile() | |||
| 591 | + | } | |||
| 592 | + | ||||
| 417 | // getAllDefaultKeys returns all keys from the default configuration. | 593 | // getAllDefaultKeys returns all keys from the default configuration. | ||
| 418 | func (m *Migrator) getAllDefaultKeys() []string { | 594 | func (m *Migrator) getAllDefaultKeys() []string { | ||
| 419 | keys := m.defaultViper.AllKeys() | 595 | keys := m.defaultViper.AllKeys() | ||
M internal/infrastructure/config/migrate_test.go +245 -0
| @@ -628,3 +628,248 @@ func TestMigrator_Migrate_NoConfigFile(t *testing.T) { | |||
| 628 | 628 | // which is difficult without refactoring. Skip for now. | |
| 629 | 629 | t.Skip("requires config path mocking") | |
| 630 | 630 | } | |
| 631 | + | ||
| 632 | + | func TestMigrator_GetConfigType(t *testing.T) { | |
| 633 | + | m := NewMigrator() | |
| 634 | + | ||
| 635 | + | tests := []struct { | |
| 636 | + | name string | |
| 637 | + | configFile string | |
| 638 | + | expected string | |
| 639 | + | }{ | |
| 640 | + | { | |
| 641 | + | name: "toml file", | |
| 642 | + | configFile: "/path/to/config.toml", | |
| 643 | + | expected: "toml", | |
| 644 | + | }, | |
| 645 | + | { | |
| 646 | + | name: "yaml file", | |
| 647 | + | configFile: "/path/to/config.yaml", | |
| 648 | + | expected: "yaml", | |
| 649 | + | }, | |
| 650 | + | { | |
| 651 | + | name: "yml file", | |
| 652 | + | configFile: "/path/to/config.yml", | |
| 653 | + | expected: "yaml", | |
| 654 | + | }, | |
| 655 | + | { | |
| 656 | + | name: "json file", | |
| 657 | + | configFile: "/path/to/config.json", | |
| 658 | + | expected: "json", | |
| 659 | + | }, | |
| 660 | + | { | |
| 661 | + | name: "unknown extension defaults to toml", | |
| 662 | + | configFile: "/path/to/config.unknown", | |
| 663 | + | expected: "toml", | |
| 664 | + | }, | |
| 665 | + | { | |
| 666 | + | name: "no extension defaults to toml", | |
| 667 | + | configFile: "/path/to/config", | |
| 668 | + | expected: "toml", | |
| 669 | + | }, | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | for _, tt := range tests { | |
| 673 | + | t.Run(tt.name, func(t *testing.T) { | |
| 674 | + | result := m.getConfigType(tt.configFile) | |
| 675 | + | assert.Equal(t, tt.expected, result) | |
| 676 | + | }) | |
| 677 | + | } | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func TestMigrator_GetNestedValue(t *testing.T) { | |
| 681 | + | m := NewMigrator() | |
| 682 | + | ||
| 683 | + | config := map[string]any{ | |
| 684 | + | "level1": map[string]any{ | |
| 685 | + | "level2": map[string]any{ | |
| 686 | + | "level3": "deep_value", | |
| 687 | + | }, | |
| 688 | + | "simple": "simple_value", | |
| 689 | + | }, | |
| 690 | + | "top": "top_value", | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | tests := []struct { | |
| 694 | + | name string | |
| 695 | + | key string | |
| 696 | + | expected any | |
| 697 | + | }{ | |
| 698 | + | { | |
| 699 | + | name: "top level key", | |
| 700 | + | key: "top", | |
| 701 | + | expected: "top_value", | |
| 702 | + | }, | |
| 703 | + | { | |
| 704 | + | name: "nested key", | |
| 705 | + | key: "level1.simple", | |
| 706 | + | expected: "simple_value", | |
| 707 | + | }, | |
| 708 | + | { | |
| 709 | + | name: "deeply nested key", | |
| 710 | + | key: "level1.level2.level3", | |
| 711 | + | expected: "deep_value", | |
| 712 | + | }, | |
| 713 | + | { | |
| 714 | + | name: "non-existent key", | |
| 715 | + | key: "nonexistent", | |
| 716 | + | expected: nil, | |
| 717 | + | }, | |
| 718 | + | { | |
| 719 | + | name: "non-existent nested key", | |
| 720 | + | key: "level1.nonexistent", | |
| 721 | + | expected: nil, | |
| 722 | + | }, | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | for _, tt := range tests { | |
| 726 | + | t.Run(tt.name, func(t *testing.T) { | |
| 727 | + | result := m.getNestedValue(config, tt.key) | |
| 728 | + | assert.Equal(t, tt.expected, result) | |
| 729 | + | }) | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | func TestMigrator_SetNestedValue(t *testing.T) { | |
| 734 | + | m := NewMigrator() | |
| 735 | + | ||
| 736 | + | t.Run("set top level value", func(t *testing.T) { | |
| 737 | + | config := make(map[string]any) | |
| 738 | + | m.setNestedValue(config, "top", "value") | |
| 739 | + | assert.Equal(t, "value", config["top"]) | |
| 740 | + | }) | |
| 741 | + | ||
| 742 | + | t.Run("set nested value with existing parent", func(t *testing.T) { | |
| 743 | + | config := map[string]any{ | |
| 744 | + | "parent": map[string]any{}, | |
| 745 | + | } | |
| 746 | + | m.setNestedValue(config, "parent.child", "value") | |
| 747 | + | parent := config["parent"].(map[string]any) | |
| 748 | + | assert.Equal(t, "value", parent["child"]) | |
| 749 | + | }) | |
| 750 | + | ||
| 751 | + | t.Run("set nested value creates intermediate maps", func(t *testing.T) { | |
| 752 | + | config := make(map[string]any) | |
| 753 | + | m.setNestedValue(config, "a.b.c", "deep_value") | |
| 754 | + | ||
| 755 | + | a := config["a"].(map[string]any) | |
| 756 | + | b := a["b"].(map[string]any) | |
| 757 | + | assert.Equal(t, "deep_value", b["c"]) | |
| 758 | + | }) | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | func TestMigrator_DeleteNestedKey(t *testing.T) { | |
| 762 | + | m := NewMigrator() | |
| 763 | + | ||
| 764 | + | t.Run("delete top level key", func(t *testing.T) { | |
| 765 | + | config := map[string]any{ | |
| 766 | + | "keep": "value", | |
| 767 | + | "delete": "value", | |
| 768 | + | } | |
| 769 | + | m.deleteNestedKey(config, "delete") | |
| 770 | + | assert.Contains(t, config, "keep") | |
| 771 | + | assert.NotContains(t, config, "delete") | |
| 772 | + | }) | |
| 773 | + | ||
| 774 | + | t.Run("delete nested key", func(t *testing.T) { | |
| 775 | + | config := map[string]any{ | |
| 776 | + | "parent": map[string]any{ | |
| 777 | + | "keep": "value", | |
| 778 | + | "delete": "value", | |
| 779 | + | }, | |
| 780 | + | } | |
| 781 | + | m.deleteNestedKey(config, "parent.delete") | |
| 782 | + | parent := config["parent"].(map[string]any) | |
| 783 | + | assert.Contains(t, parent, "keep") | |
| 784 | + | assert.NotContains(t, parent, "delete") | |
| 785 | + | }) | |
| 786 | + | ||
| 787 | + | t.Run("delete nested key cleans up empty parents", func(t *testing.T) { | |
| 788 | + | config := map[string]any{ | |
| 789 | + | "keep": "value", | |
| 790 | + | "parent": map[string]any{ | |
| 791 | + | "child": map[string]any{ | |
| 792 | + | "leaf": "value", | |
| 793 | + | }, | |
| 794 | + | }, | |
| 795 | + | } | |
| 796 | + | m.deleteNestedKey(config, "parent.child.leaf") | |
| 797 | + | // Both parent and child should be removed since they're empty | |
| 798 | + | assert.NotContains(t, config, "parent") | |
| 799 | + | assert.Contains(t, config, "keep") | |
| 800 | + | }) | |
| 801 | + | ||
| 802 | + | t.Run("delete non-existent key is no-op", func(t *testing.T) { | |
| 803 | + | config := map[string]any{ | |
| 804 | + | "keep": "value", | |
| 805 | + | } | |
| 806 | + | m.deleteNestedKey(config, "nonexistent") | |
| 807 | + | assert.Contains(t, config, "keep") | |
| 808 | + | }) | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | func TestMigrator_ReadRawConfig(t *testing.T) { | |
| 812 | + | m := NewMigrator() | |
| 813 | + | ||
| 814 | + | t.Run("read TOML config", func(t *testing.T) { | |
| 815 | + | tmpDir := t.TempDir() | |
| 816 | + | configFile := filepath.Join(tmpDir, "config.toml") | |
| 817 | + | ||
| 818 | + | content := ` | |
| 819 | + | [section] | |
| 820 | + | key = "value" | |
| 821 | + | number = 42 | |
| 822 | + | ` | |
| 823 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |
| 824 | + | require.NoError(t, err) | |
| 825 | + | ||
| 826 | + | config, err := m.readRawConfig(configFile) | |
| 827 | + | require.NoError(t, err) | |
| 828 | + | ||
| 829 | + | section := config["section"].(map[string]any) | |
| 830 | + | assert.Equal(t, "value", section["key"]) | |
| 831 | + | assert.Equal(t, int64(42), section["number"]) | |
| 832 | + | }) | |
| 833 | + | ||
| 834 | + | t.Run("read YAML config", func(t *testing.T) { | |
| 835 | + | tmpDir := t.TempDir() | |
| 836 | + | configFile := filepath.Join(tmpDir, "config.yaml") | |
| 837 | + | ||
| 838 | + | content := ` | |
| 839 | + | section: | |
| 840 | + | key: value | |
| 841 | + | number: 42 | |
| 842 | + | ` | |
| 843 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |
| 844 | + | require.NoError(t, err) | |
| 845 | + | ||
| 846 | + | config, err := m.readRawConfig(configFile) | |
| 847 | + | require.NoError(t, err) | |
| 848 | + | ||
| 849 | + | section := config["section"].(map[string]any) | |
| 850 | + | assert.Equal(t, "value", section["key"]) | |
| 851 | + | assert.Equal(t, 42, section["number"]) | |
| 852 | + | }) | |
| 853 | + | ||
| 854 | + | t.Run("read JSON config", func(t *testing.T) { | |
| 855 | + | tmpDir := t.TempDir() | |
| 856 | + | configFile := filepath.Join(tmpDir, "config.json") | |
| 857 | + | ||
| 858 | + | content := `{ | |
| 859 | + | "section": { | |
| 860 | + | "key": "value", | |
| 861 | + | "number": 42 | |
| 862 | + | } | |
| 863 | + | }` | |
| 864 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |
| 865 | + | require.NoError(t, err) | |
| 866 | + | ||
| 867 | + | config, err := m.readRawConfig(configFile) | |
| 868 | + | require.NoError(t, err) | |
| 869 | + | ||
| 870 | + | section := config["section"].(map[string]any) | |
| 871 | + | assert.Equal(t, "value", section["key"]) | |
| 872 | + | // JSON numbers are float64 | |
| 873 | + | assert.InDelta(t, 42.0, section["number"].(float64), 0.001) | |
| 874 | + | }) | |
| 875 | + | } | |
| @@ -628,3 +628,248 @@ func TestMigrator_Migrate_NoConfigFile(t *testing.T) { | |||||
| 628 | // which is difficult without refactoring. Skip for now. | 628 | // which is difficult without refactoring. Skip for now. | ||
| 629 | t.Skip("requires config path mocking") | 629 | t.Skip("requires config path mocking") | ||
| 630 | } | 630 | } | ||
| 631 | + | ||||
| 632 | + | func TestMigrator_GetConfigType(t *testing.T) { | |||
| 633 | + | m := NewMigrator() | |||
| 634 | + | ||||
| 635 | + | tests := []struct { | |||
| 636 | + | name string | |||
| 637 | + | configFile string | |||
| 638 | + | expected string | |||
| 639 | + | }{ | |||
| 640 | + | { | |||
| 641 | + | name: "toml file", | |||
| 642 | + | configFile: "/path/to/config.toml", | |||
| 643 | + | expected: "toml", | |||
| 644 | + | }, | |||
| 645 | + | { | |||
| 646 | + | name: "yaml file", | |||
| 647 | + | configFile: "/path/to/config.yaml", | |||
| 648 | + | expected: "yaml", | |||
| 649 | + | }, | |||
| 650 | + | { | |||
| 651 | + | name: "yml file", | |||
| 652 | + | configFile: "/path/to/config.yml", | |||
| 653 | + | expected: "yaml", | |||
| 654 | + | }, | |||
| 655 | + | { | |||
| 656 | + | name: "json file", | |||
| 657 | + | configFile: "/path/to/config.json", | |||
| 658 | + | expected: "json", | |||
| 659 | + | }, | |||
| 660 | + | { | |||
| 661 | + | name: "unknown extension defaults to toml", | |||
| 662 | + | configFile: "/path/to/config.unknown", | |||
| 663 | + | expected: "toml", | |||
| 664 | + | }, | |||
| 665 | + | { | |||
| 666 | + | name: "no extension defaults to toml", | |||
| 667 | + | configFile: "/path/to/config", | |||
| 668 | + | expected: "toml", | |||
| 669 | + | }, | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | for _, tt := range tests { | |||
| 673 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 674 | + | result := m.getConfigType(tt.configFile) | |||
| 675 | + | assert.Equal(t, tt.expected, result) | |||
| 676 | + | }) | |||
| 677 | + | } | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | func TestMigrator_GetNestedValue(t *testing.T) { | |||
| 681 | + | m := NewMigrator() | |||
| 682 | + | ||||
| 683 | + | config := map[string]any{ | |||
| 684 | + | "level1": map[string]any{ | |||
| 685 | + | "level2": map[string]any{ | |||
| 686 | + | "level3": "deep_value", | |||
| 687 | + | }, | |||
| 688 | + | "simple": "simple_value", | |||
| 689 | + | }, | |||
| 690 | + | "top": "top_value", | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | tests := []struct { | |||
| 694 | + | name string | |||
| 695 | + | key string | |||
| 696 | + | expected any | |||
| 697 | + | }{ | |||
| 698 | + | { | |||
| 699 | + | name: "top level key", | |||
| 700 | + | key: "top", | |||
| 701 | + | expected: "top_value", | |||
| 702 | + | }, | |||
| 703 | + | { | |||
| 704 | + | name: "nested key", | |||
| 705 | + | key: "level1.simple", | |||
| 706 | + | expected: "simple_value", | |||
| 707 | + | }, | |||
| 708 | + | { | |||
| 709 | + | name: "deeply nested key", | |||
| 710 | + | key: "level1.level2.level3", | |||
| 711 | + | expected: "deep_value", | |||
| 712 | + | }, | |||
| 713 | + | { | |||
| 714 | + | name: "non-existent key", | |||
| 715 | + | key: "nonexistent", | |||
| 716 | + | expected: nil, | |||
| 717 | + | }, | |||
| 718 | + | { | |||
| 719 | + | name: "non-existent nested key", | |||
| 720 | + | key: "level1.nonexistent", | |||
| 721 | + | expected: nil, | |||
| 722 | + | }, | |||
| 723 | + | } | |||
| 724 | + | ||||
| 725 | + | for _, tt := range tests { | |||
| 726 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 727 | + | result := m.getNestedValue(config, tt.key) | |||
| 728 | + | assert.Equal(t, tt.expected, result) | |||
| 729 | + | }) | |||
| 730 | + | } | |||
| 731 | + | } | |||
| 732 | + | ||||
| 733 | + | func TestMigrator_SetNestedValue(t *testing.T) { | |||
| 734 | + | m := NewMigrator() | |||
| 735 | + | ||||
| 736 | + | t.Run("set top level value", func(t *testing.T) { | |||
| 737 | + | config := make(map[string]any) | |||
| 738 | + | m.setNestedValue(config, "top", "value") | |||
| 739 | + | assert.Equal(t, "value", config["top"]) | |||
| 740 | + | }) | |||
| 741 | + | ||||
| 742 | + | t.Run("set nested value with existing parent", func(t *testing.T) { | |||
| 743 | + | config := map[string]any{ | |||
| 744 | + | "parent": map[string]any{}, | |||
| 745 | + | } | |||
| 746 | + | m.setNestedValue(config, "parent.child", "value") | |||
| 747 | + | parent := config["parent"].(map[string]any) | |||
| 748 | + | assert.Equal(t, "value", parent["child"]) | |||
| 749 | + | }) | |||
| 750 | + | ||||
| 751 | + | t.Run("set nested value creates intermediate maps", func(t *testing.T) { | |||
| 752 | + | config := make(map[string]any) | |||
| 753 | + | m.setNestedValue(config, "a.b.c", "deep_value") | |||
| 754 | + | ||||
| 755 | + | a := config["a"].(map[string]any) | |||
| 756 | + | b := a["b"].(map[string]any) | |||
| 757 | + | assert.Equal(t, "deep_value", b["c"]) | |||
| 758 | + | }) | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | func TestMigrator_DeleteNestedKey(t *testing.T) { | |||
| 762 | + | m := NewMigrator() | |||
| 763 | + | ||||
| 764 | + | t.Run("delete top level key", func(t *testing.T) { | |||
| 765 | + | config := map[string]any{ | |||
| 766 | + | "keep": "value", | |||
| 767 | + | "delete": "value", | |||
| 768 | + | } | |||
| 769 | + | m.deleteNestedKey(config, "delete") | |||
| 770 | + | assert.Contains(t, config, "keep") | |||
| 771 | + | assert.NotContains(t, config, "delete") | |||
| 772 | + | }) | |||
| 773 | + | ||||
| 774 | + | t.Run("delete nested key", func(t *testing.T) { | |||
| 775 | + | config := map[string]any{ | |||
| 776 | + | "parent": map[string]any{ | |||
| 777 | + | "keep": "value", | |||
| 778 | + | "delete": "value", | |||
| 779 | + | }, | |||
| 780 | + | } | |||
| 781 | + | m.deleteNestedKey(config, "parent.delete") | |||
| 782 | + | parent := config["parent"].(map[string]any) | |||
| 783 | + | assert.Contains(t, parent, "keep") | |||
| 784 | + | assert.NotContains(t, parent, "delete") | |||
| 785 | + | }) | |||
| 786 | + | ||||
| 787 | + | t.Run("delete nested key cleans up empty parents", func(t *testing.T) { | |||
| 788 | + | config := map[string]any{ | |||
| 789 | + | "keep": "value", | |||
| 790 | + | "parent": map[string]any{ | |||
| 791 | + | "child": map[string]any{ | |||
| 792 | + | "leaf": "value", | |||
| 793 | + | }, | |||
| 794 | + | }, | |||
| 795 | + | } | |||
| 796 | + | m.deleteNestedKey(config, "parent.child.leaf") | |||
| 797 | + | // Both parent and child should be removed since they're empty | |||
| 798 | + | assert.NotContains(t, config, "parent") | |||
| 799 | + | assert.Contains(t, config, "keep") | |||
| 800 | + | }) | |||
| 801 | + | ||||
| 802 | + | t.Run("delete non-existent key is no-op", func(t *testing.T) { | |||
| 803 | + | config := map[string]any{ | |||
| 804 | + | "keep": "value", | |||
| 805 | + | } | |||
| 806 | + | m.deleteNestedKey(config, "nonexistent") | |||
| 807 | + | assert.Contains(t, config, "keep") | |||
| 808 | + | }) | |||
| 809 | + | } | |||
| 810 | + | ||||
| 811 | + | func TestMigrator_ReadRawConfig(t *testing.T) { | |||
| 812 | + | m := NewMigrator() | |||
| 813 | + | ||||
| 814 | + | t.Run("read TOML config", func(t *testing.T) { | |||
| 815 | + | tmpDir := t.TempDir() | |||
| 816 | + | configFile := filepath.Join(tmpDir, "config.toml") | |||
| 817 | + | ||||
| 818 | + | content := ` | |||
| 819 | + | [section] | |||
| 820 | + | key = "value" | |||
| 821 | + | number = 42 | |||
| 822 | + | ` | |||
| 823 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |||
| 824 | + | require.NoError(t, err) | |||
| 825 | + | ||||
| 826 | + | config, err := m.readRawConfig(configFile) | |||
| 827 | + | require.NoError(t, err) | |||
| 828 | + | ||||
| 829 | + | section := config["section"].(map[string]any) | |||
| 830 | + | assert.Equal(t, "value", section["key"]) | |||
| 831 | + | assert.Equal(t, int64(42), section["number"]) | |||
| 832 | + | }) | |||
| 833 | + | ||||
| 834 | + | t.Run("read YAML config", func(t *testing.T) { | |||
| 835 | + | tmpDir := t.TempDir() | |||
| 836 | + | configFile := filepath.Join(tmpDir, "config.yaml") | |||
| 837 | + | ||||
| 838 | + | content := ` | |||
| 839 | + | section: | |||
| 840 | + | key: value | |||
| 841 | + | number: 42 | |||
| 842 | + | ` | |||
| 843 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |||
| 844 | + | require.NoError(t, err) | |||
| 845 | + | ||||
| 846 | + | config, err := m.readRawConfig(configFile) | |||
| 847 | + | require.NoError(t, err) | |||
| 848 | + | ||||
| 849 | + | section := config["section"].(map[string]any) | |||
| 850 | + | assert.Equal(t, "value", section["key"]) | |||
| 851 | + | assert.Equal(t, 42, section["number"]) | |||
| 852 | + | }) | |||
| 853 | + | ||||
| 854 | + | t.Run("read JSON config", func(t *testing.T) { | |||
| 855 | + | tmpDir := t.TempDir() | |||
| 856 | + | configFile := filepath.Join(tmpDir, "config.json") | |||
| 857 | + | ||||
| 858 | + | content := `{ | |||
| 859 | + | "section": { | |||
| 860 | + | "key": "value", | |||
| 861 | + | "number": 42 | |||
| 862 | + | } | |||
| 863 | + | }` | |||
| 864 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |||
| 865 | + | require.NoError(t, err) | |||
| 866 | + | ||||
| 867 | + | config, err := m.readRawConfig(configFile) | |||
| 868 | + | require.NoError(t, err) | |||
| 869 | + | ||||
| 870 | + | section := config["section"].(map[string]any) | |||
| 871 | + | assert.Equal(t, "value", section["key"]) | |||
| 872 | + | // JSON numbers are float64 | |||
| 873 | + | assert.InDelta(t, 42.0, section["number"].(float64), 0.001) | |||
| 874 | + | }) | |||
| 875 | + | } | |||
A internal/infrastructure/config/schema_provider.go +903 -0
| @@ -0,0 +1,903 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Section names for grouping config keys. | |
| 10 | + | const ( | |
| 11 | + | SectionAppearance = "Appearance" | |
| 12 | + | SectionLogging = "Logging" | |
| 13 | + | SectionHistory = "History" | |
| 14 | + | SectionWorkspace = "Workspace" | |
| 15 | + | SectionSession = "Session" | |
| 16 | + | SectionOmnibox = "Omnibox" | |
| 17 | + | SectionContentFiltering = "Content Filtering" | |
| 18 | + | SectionRendering = "Rendering" | |
| 19 | + | SectionMedia = "Media" | |
| 20 | + | SectionUpdate = "Update" | |
| 21 | + | SectionDmenu = "Dmenu" | |
| 22 | + | SectionDebug = "Debug" | |
| 23 | + | SectionPerformance = "Performance" | |
| 24 | + | SectionRuntime = "Runtime" | |
| 25 | + | SectionDatabase = "Database" | |
| 26 | + | SectionSearch = "Search" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // SchemaProvider implements port.ConfigSchemaProvider. | |
| 30 | + | type SchemaProvider struct{} | |
| 31 | + | ||
| 32 | + | // NewSchemaProvider creates a new SchemaProvider. | |
| 33 | + | func NewSchemaProvider() *SchemaProvider { | |
| 34 | + | return &SchemaProvider{} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // GetSchema returns all configuration keys with their metadata. | |
| 38 | + | func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |
| 39 | + | defaults := DefaultConfig() | |
| 40 | + | ||
| 41 | + | keys := make([]entity.ConfigKeyInfo, 0, 100) | |
| 42 | + | ||
| 43 | + | // Appearance section | |
| 44 | + | keys = append(keys, p.getAppearanceKeys(defaults)...) | |
| 45 | + | ||
| 46 | + | // Logging section | |
| 47 | + | keys = append(keys, p.getLoggingKeys(defaults)...) | |
| 48 | + | ||
| 49 | + | // History section | |
| 50 | + | keys = append(keys, p.getHistoryKeys(defaults)...) | |
| 51 | + | ||
| 52 | + | // Search section | |
| 53 | + | keys = append(keys, p.getSearchKeys(defaults)...) | |
| 54 | + | ||
| 55 | + | // Dmenu section | |
| 56 | + | keys = append(keys, p.getDmenuKeys(defaults)...) | |
| 57 | + | ||
| 58 | + | // Workspace section | |
| 59 | + | keys = append(keys, p.getWorkspaceKeys(defaults)...) | |
| 60 | + | ||
| 61 | + | // Session section | |
| 62 | + | keys = append(keys, p.getSessionKeys(defaults)...) | |
| 63 | + | ||
| 64 | + | // Omnibox section | |
| 65 | + | keys = append(keys, p.getOmniboxKeys(defaults)...) | |
| 66 | + | ||
| 67 | + | // Content Filtering section | |
| 68 | + | keys = append(keys, p.getContentFilteringKeys(defaults)...) | |
| 69 | + | ||
| 70 | + | // Rendering section | |
| 71 | + | keys = append(keys, p.getRenderingKeys(defaults)...) | |
| 72 | + | ||
| 73 | + | // Media section | |
| 74 | + | keys = append(keys, p.getMediaKeys(defaults)...) | |
| 75 | + | ||
| 76 | + | // Update section | |
| 77 | + | keys = append(keys, p.getUpdateKeys(defaults)...) | |
| 78 | + | ||
| 79 | + | // Debug section | |
| 80 | + | keys = append(keys, p.getDebugKeys(defaults)...) | |
| 81 | + | ||
| 82 | + | // Performance section | |
| 83 | + | keys = append(keys, p.getPerformanceKeys(defaults)...) | |
| 84 | + | ||
| 85 | + | // Runtime section | |
| 86 | + | keys = append(keys, p.getRuntimeKeys(defaults)...) | |
| 87 | + | ||
| 88 | + | // Database section | |
| 89 | + | keys = append(keys, p.getDatabaseKeys()...) | |
| 90 | + | ||
| 91 | + | return keys | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (*SchemaProvider) getAppearanceKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 95 | + | return []entity.ConfigKeyInfo{ | |
| 96 | + | { | |
| 97 | + | Key: "appearance.color_scheme", | |
| 98 | + | Type: "string", | |
| 99 | + | Default: defaults.Appearance.ColorScheme, | |
| 100 | + | Description: "Theme preference (follows system by default)", | |
| 101 | + | Values: []string{"default", "prefer-dark", "prefer-light"}, | |
| 102 | + | Section: SectionAppearance, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | Key: "appearance.default_font_size", | |
| 106 | + | Type: "int", | |
| 107 | + | Default: fmt.Sprintf("%d", defaults.Appearance.DefaultFontSize), | |
| 108 | + | Description: "Default font size in CSS pixels", | |
| 109 | + | Range: "1-72", | |
| 110 | + | Section: SectionAppearance, | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | Key: "appearance.sans_font", | |
| 114 | + | Type: "string", | |
| 115 | + | Default: defaults.Appearance.SansFont, | |
| 116 | + | Description: "Sans-serif font family for web pages", | |
| 117 | + | Section: SectionAppearance, | |
| 118 | + | }, | |
| 119 | + | { | |
| 120 | + | Key: "appearance.serif_font", | |
| 121 | + | Type: "string", | |
| 122 | + | Default: defaults.Appearance.SerifFont, | |
| 123 | + | Description: "Serif font family for web pages", | |
| 124 | + | Section: SectionAppearance, | |
| 125 | + | }, | |
| 126 | + | { | |
| 127 | + | Key: "appearance.monospace_font", | |
| 128 | + | Type: "string", | |
| 129 | + | Default: defaults.Appearance.MonospaceFont, | |
| 130 | + | Description: "Monospace font family for code", | |
| 131 | + | Section: SectionAppearance, | |
| 132 | + | }, | |
| 133 | + | { | |
| 134 | + | Key: "default_webpage_zoom", | |
| 135 | + | Type: "float64", | |
| 136 | + | Default: fmt.Sprintf("%.1f", defaults.DefaultWebpageZoom), | |
| 137 | + | Description: "Default zoom level for web pages (1.0 = 100%%)", | |
| 138 | + | Range: "0.1-5.0", | |
| 139 | + | Section: SectionAppearance, | |
| 140 | + | }, | |
| 141 | + | { | |
| 142 | + | Key: "default_ui_scale", | |
| 143 | + | Type: "float64", | |
| 144 | + | Default: fmt.Sprintf("%.1f", defaults.DefaultUIScale), | |
| 145 | + | Description: "UI scale multiplier for GTK widgets", | |
| 146 | + | Range: "0.5-3.0", | |
| 147 | + | Section: SectionAppearance, | |
| 148 | + | }, | |
| 149 | + | { | |
| 150 | + | Key: "appearance.light_palette.*", | |
| 151 | + | Type: "string", | |
| 152 | + | Default: "(hex colors)", | |
| 153 | + | Description: "Light theme color palette (background, surface, surface_variant, text, muted, accent, border)", | |
| 154 | + | Section: SectionAppearance, | |
| 155 | + | }, | |
| 156 | + | { | |
| 157 | + | Key: "appearance.dark_palette.*", | |
| 158 | + | Type: "string", | |
| 159 | + | Default: "(hex colors)", | |
| 160 | + | Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)", | |
| 161 | + | Section: SectionAppearance, | |
| 162 | + | }, | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (*SchemaProvider) getLoggingKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 167 | + | return []entity.ConfigKeyInfo{ | |
| 168 | + | { | |
| 169 | + | Key: "logging.level", | |
| 170 | + | Type: "string", | |
| 171 | + | Default: defaults.Logging.Level, | |
| 172 | + | Description: "Log verbosity level", | |
| 173 | + | Values: []string{"trace", "debug", "info", "warn", "error", "fatal"}, | |
| 174 | + | Section: SectionLogging, | |
| 175 | + | }, | |
| 176 | + | { | |
| 177 | + | Key: "logging.format", | |
| 178 | + | Type: "string", | |
| 179 | + | Default: defaults.Logging.Format, | |
| 180 | + | Description: "Log output format", | |
| 181 | + | Values: []string{"text", "json", "console"}, | |
| 182 | + | Section: SectionLogging, | |
| 183 | + | }, | |
| 184 | + | { | |
| 185 | + | Key: "logging.max_age", | |
| 186 | + | Type: "int", | |
| 187 | + | Default: fmt.Sprintf("%d", defaults.Logging.MaxAge), | |
| 188 | + | Description: "Maximum age of log files in days", | |
| 189 | + | Range: ">=0", | |
| 190 | + | Section: SectionLogging, | |
| 191 | + | }, | |
| 192 | + | { | |
| 193 | + | Key: "logging.log_dir", | |
| 194 | + | Type: "string", | |
| 195 | + | Default: defaults.Logging.LogDir, | |
| 196 | + | Description: "Directory for log files", | |
| 197 | + | Section: SectionLogging, | |
| 198 | + | }, | |
| 199 | + | { | |
| 200 | + | Key: "logging.enable_file_log", | |
| 201 | + | Type: "bool", | |
| 202 | + | Default: fmt.Sprintf("%t", defaults.Logging.EnableFileLog), | |
| 203 | + | Description: "Enable logging to file", | |
| 204 | + | Section: SectionLogging, | |
| 205 | + | }, | |
| 206 | + | { | |
| 207 | + | Key: "logging.capture_console", | |
| 208 | + | Type: "bool", | |
| 209 | + | Default: fmt.Sprintf("%t", defaults.Logging.CaptureConsole), | |
| 210 | + | Description: "Capture browser console messages to log", | |
| 211 | + | Section: SectionLogging, | |
| 212 | + | }, | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (*SchemaProvider) getHistoryKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 217 | + | return []entity.ConfigKeyInfo{ | |
| 218 | + | { | |
| 219 | + | Key: "history.max_entries", | |
| 220 | + | Type: "int", | |
| 221 | + | Default: fmt.Sprintf("%d", defaults.History.MaxEntries), | |
| 222 | + | Description: "Maximum number of history entries to keep", | |
| 223 | + | Range: ">=0", | |
| 224 | + | Section: SectionHistory, | |
| 225 | + | }, | |
| 226 | + | { | |
| 227 | + | Key: "history.retention_period_days", | |
| 228 | + | Type: "int", | |
| 229 | + | Default: fmt.Sprintf("%d", defaults.History.RetentionPeriodDays), | |
| 230 | + | Description: "Number of days to retain history", | |
| 231 | + | Range: ">=0", | |
| 232 | + | Section: SectionHistory, | |
| 233 | + | }, | |
| 234 | + | { | |
| 235 | + | Key: "history.cleanup_interval_days", | |
| 236 | + | Type: "int", | |
| 237 | + | Default: fmt.Sprintf("%d", defaults.History.CleanupIntervalDays), | |
| 238 | + | Description: "Days between automatic history cleanup", | |
| 239 | + | Range: ">=0", | |
| 240 | + | Section: SectionHistory, | |
| 241 | + | }, | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 246 | + | return []entity.ConfigKeyInfo{ | |
| 247 | + | { | |
| 248 | + | Key: "default_search_engine", | |
| 249 | + | Type: "string", | |
| 250 | + | Default: defaults.DefaultSearchEngine, | |
| 251 | + | Description: "Default search engine URL (must contain %s placeholder)", | |
| 252 | + | Section: SectionSearch, | |
| 253 | + | }, | |
| 254 | + | { | |
| 255 | + | Key: "search_shortcuts.<key>", | |
| 256 | + | Type: "object", | |
| 257 | + | Default: "(see defaults)", | |
| 258 | + | Description: "Search shortcuts map with url and description fields", | |
| 259 | + | Section: SectionSearch, | |
| 260 | + | }, | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 265 | + | return []entity.ConfigKeyInfo{ | |
| 266 | + | { | |
| 267 | + | Key: "dmenu.max_history_items", | |
| 268 | + | Type: "int", | |
| 269 | + | Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems), | |
| 270 | + | Description: "Maximum history items shown in dmenu", | |
| 271 | + | Range: ">=0", | |
| 272 | + | Section: SectionDmenu, | |
| 273 | + | }, | |
| 274 | + | { | |
| 275 | + | Key: "dmenu.show_visit_count", | |
| 276 | + | Type: "bool", | |
| 277 | + | Default: fmt.Sprintf("%t", defaults.Dmenu.ShowVisitCount), | |
| 278 | + | Description: "Show visit count in dmenu entries", | |
| 279 | + | Section: SectionDmenu, | |
| 280 | + | }, | |
| 281 | + | { | |
| 282 | + | Key: "dmenu.show_last_visited", | |
| 283 | + | Type: "bool", | |
| 284 | + | Default: fmt.Sprintf("%t", defaults.Dmenu.ShowLastVisited), | |
| 285 | + | Description: "Show last visited date in dmenu entries", | |
| 286 | + | Section: SectionDmenu, | |
| 287 | + | }, | |
| 288 | + | { | |
| 289 | + | Key: "dmenu.history_prefix", | |
| 290 | + | Type: "string", | |
| 291 | + | Default: defaults.Dmenu.HistoryPrefix, | |
| 292 | + | Description: "Prefix for history entries in dmenu", | |
| 293 | + | Section: SectionDmenu, | |
| 294 | + | }, | |
| 295 | + | { | |
| 296 | + | Key: "dmenu.shortcut_prefix", | |
| 297 | + | Type: "string", | |
| 298 | + | Default: defaults.Dmenu.ShortcutPrefix, | |
| 299 | + | Description: "Prefix for search shortcuts in dmenu", | |
| 300 | + | Section: SectionDmenu, | |
| 301 | + | }, | |
| 302 | + | { | |
| 303 | + | Key: "dmenu.url_prefix", | |
| 304 | + | Type: "string", | |
| 305 | + | Default: defaults.Dmenu.URLPrefix, | |
| 306 | + | Description: "Prefix for URL entries in dmenu", | |
| 307 | + | Section: SectionDmenu, | |
| 308 | + | }, | |
| 309 | + | { | |
| 310 | + | Key: "dmenu.date_format", | |
| 311 | + | Type: "string", | |
| 312 | + | Default: defaults.Dmenu.DateFormat, | |
| 313 | + | Description: "Go time format for dates in dmenu", | |
| 314 | + | Section: SectionDmenu, | |
| 315 | + | }, | |
| 316 | + | { | |
| 317 | + | Key: "dmenu.sort_by_visit_count", | |
| 318 | + | Type: "bool", | |
| 319 | + | Default: fmt.Sprintf("%t", defaults.Dmenu.SortByVisitCount), | |
| 320 | + | Description: "Sort dmenu entries by visit count", | |
| 321 | + | Section: SectionDmenu, | |
| 322 | + | }, | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 327 | + | return []entity.ConfigKeyInfo{ | |
| 328 | + | { | |
| 329 | + | Key: "workspace.new_pane_url", | |
| 330 | + | Type: "string", | |
| 331 | + | Default: defaults.Workspace.NewPaneURL, | |
| 332 | + | Description: "URL loaded when creating a new pane", | |
| 333 | + | Section: SectionWorkspace, | |
| 334 | + | }, | |
| 335 | + | { | |
| 336 | + | Key: "workspace.tab_bar_position", | |
| 337 | + | Type: "string", | |
| 338 | + | Default: defaults.Workspace.TabBarPosition, | |
| 339 | + | Description: "Position of the tab bar", | |
| 340 | + | Values: []string{"top", "bottom"}, | |
| 341 | + | Section: SectionWorkspace, | |
| 342 | + | }, | |
| 343 | + | { | |
| 344 | + | Key: "workspace.hide_tab_bar_when_single_tab", | |
| 345 | + | Type: "bool", | |
| 346 | + | Default: fmt.Sprintf("%t", defaults.Workspace.HideTabBarWhenSingleTab), | |
| 347 | + | Description: "Hide tab bar when only one tab exists", | |
| 348 | + | Section: SectionWorkspace, | |
| 349 | + | }, | |
| 350 | + | { | |
| 351 | + | Key: "workspace.switch_to_tab_on_move", | |
| 352 | + | Type: "bool", | |
| 353 | + | Default: fmt.Sprintf("%t", defaults.Workspace.SwitchToTabOnMove), | |
| 354 | + | Description: "Switch focus to tab when moving pane to it", | |
| 355 | + | Section: SectionWorkspace, | |
| 356 | + | }, | |
| 357 | + | // Pane mode | |
| 358 | + | { | |
| 359 | + | Key: "workspace.pane_mode.activation_shortcut", | |
| 360 | + | Type: "string", | |
| 361 | + | Default: defaults.Workspace.PaneMode.ActivationShortcut, | |
| 362 | + | Description: "Shortcut to enter pane mode", | |
| 363 | + | Section: SectionWorkspace, | |
| 364 | + | }, | |
| 365 | + | { | |
| 366 | + | Key: "workspace.pane_mode.timeout_ms", | |
| 367 | + | Type: "int", | |
| 368 | + | Default: fmt.Sprintf("%d", defaults.Workspace.PaneMode.TimeoutMilliseconds), | |
| 369 | + | Description: "Pane mode timeout in milliseconds (0 = no timeout)", | |
| 370 | + | Range: ">=0", | |
| 371 | + | Section: SectionWorkspace, | |
| 372 | + | }, | |
| 373 | + | { | |
| 374 | + | Key: "workspace.pane_mode.actions.<action>", | |
| 375 | + | Type: "[]string", | |
| 376 | + | Default: "(see defaults)", | |
| 377 | + | Description: "Key bindings for pane mode actions", | |
| 378 | + | Section: SectionWorkspace, | |
| 379 | + | }, | |
| 380 | + | // Tab mode | |
| 381 | + | { | |
| 382 | + | Key: "workspace.tab_mode.activation_shortcut", | |
| 383 | + | Type: "string", | |
| 384 | + | Default: defaults.Workspace.TabMode.ActivationShortcut, | |
| 385 | + | Description: "Shortcut to enter tab mode", | |
| 386 | + | Section: SectionWorkspace, | |
| 387 | + | }, | |
| 388 | + | { | |
| 389 | + | Key: "workspace.tab_mode.timeout_ms", | |
| 390 | + | Type: "int", | |
| 391 | + | Default: fmt.Sprintf("%d", defaults.Workspace.TabMode.TimeoutMilliseconds), | |
| 392 | + | Description: "Tab mode timeout in milliseconds (0 = no timeout)", | |
| 393 | + | Range: ">=0", | |
| 394 | + | Section: SectionWorkspace, | |
| 395 | + | }, | |
| 396 | + | { | |
| 397 | + | Key: "workspace.tab_mode.actions.<action>", | |
| 398 | + | Type: "[]string", | |
| 399 | + | Default: "(see defaults)", | |
| 400 | + | Description: "Key bindings for tab mode actions", | |
| 401 | + | Section: SectionWorkspace, | |
| 402 | + | }, | |
| 403 | + | // Resize mode | |
| 404 | + | { | |
| 405 | + | Key: "workspace.resize_mode.activation_shortcut", | |
| 406 | + | Type: "string", | |
| 407 | + | Default: defaults.Workspace.ResizeMode.ActivationShortcut, | |
| 408 | + | Description: "Shortcut to enter resize mode", | |
| 409 | + | Section: SectionWorkspace, | |
| 410 | + | }, | |
| 411 | + | { | |
| 412 | + | Key: "workspace.resize_mode.timeout_ms", | |
| 413 | + | Type: "int", | |
| 414 | + | Default: fmt.Sprintf("%d", defaults.Workspace.ResizeMode.TimeoutMilliseconds), | |
| 415 | + | Description: "Resize mode timeout in milliseconds (0 = no timeout)", | |
| 416 | + | Range: ">=0", | |
| 417 | + | Section: SectionWorkspace, | |
| 418 | + | }, | |
| 419 | + | { | |
| 420 | + | Key: "workspace.resize_mode.step_percent", | |
| 421 | + | Type: "float64", | |
| 422 | + | Default: fmt.Sprintf("%.1f", defaults.Workspace.ResizeMode.StepPercent), | |
| 423 | + | Description: "Percentage to resize panes per step", | |
| 424 | + | Section: SectionWorkspace, | |
| 425 | + | }, | |
| 426 | + | { | |
| 427 | + | Key: "workspace.resize_mode.min_pane_percent", | |
| 428 | + | Type: "float64", | |
| 429 | + | Default: fmt.Sprintf("%.1f", defaults.Workspace.ResizeMode.MinPanePercent), | |
| 430 | + | Description: "Minimum pane size as percentage", | |
| 431 | + | Section: SectionWorkspace, | |
| 432 | + | }, | |
| 433 | + | { | |
| 434 | + | Key: "workspace.resize_mode.actions.<action>", | |
| 435 | + | Type: "[]string", | |
| 436 | + | Default: "(see defaults)", | |
| 437 | + | Description: "Key bindings for resize mode actions", | |
| 438 | + | Section: SectionWorkspace, | |
| 439 | + | }, | |
| 440 | + | // Global shortcuts | |
| 441 | + | { | |
| 442 | + | Key: "workspace.shortcuts.close_pane", | |
| 443 | + | Type: "string", | |
| 444 | + | Default: defaults.Workspace.Shortcuts.ClosePane, | |
| 445 | + | Description: "Global shortcut to close current pane", | |
| 446 | + | Section: SectionWorkspace, | |
| 447 | + | }, | |
| 448 | + | { | |
| 449 | + | Key: "workspace.shortcuts.next_tab", | |
| 450 | + | Type: "string", | |
| 451 | + | Default: defaults.Workspace.Shortcuts.NextTab, | |
| 452 | + | Description: "Global shortcut to switch to next tab", | |
| 453 | + | Section: SectionWorkspace, | |
| 454 | + | }, | |
| 455 | + | { | |
| 456 | + | Key: "workspace.shortcuts.previous_tab", | |
| 457 | + | Type: "string", | |
| 458 | + | Default: defaults.Workspace.Shortcuts.PreviousTab, | |
| 459 | + | Description: "Global shortcut to switch to previous tab", | |
| 460 | + | Section: SectionWorkspace, | |
| 461 | + | }, | |
| 462 | + | // Popups | |
| 463 | + | { | |
| 464 | + | Key: "workspace.popups.behavior", | |
| 465 | + | Type: "string", | |
| 466 | + | Default: string(defaults.Workspace.Popups.Behavior), | |
| 467 | + | Description: "How to handle JavaScript popup windows", | |
| 468 | + | Values: []string{"split", "stacked", "tabbed", "windowed"}, | |
| 469 | + | Section: SectionWorkspace, | |
| 470 | + | }, | |
| 471 | + | { | |
| 472 | + | Key: "workspace.popups.placement", | |
| 473 | + | Type: "string", | |
| 474 | + | Default: defaults.Workspace.Popups.Placement, | |
| 475 | + | Description: "Placement direction for split popups", | |
| 476 | + | Values: []string{"right", "left", "top", "bottom"}, | |
| 477 | + | Section: SectionWorkspace, | |
| 478 | + | }, | |
| 479 | + | { | |
| 480 | + | Key: "workspace.popups.blank_target_behavior", | |
| 481 | + | Type: "string", | |
| 482 | + | Default: defaults.Workspace.Popups.BlankTargetBehavior, | |
| 483 | + | Description: "How to handle target=\"_blank\" links", | |
| 484 | + | Values: []string{"split", "stacked", "tabbed"}, | |
| 485 | + | Section: SectionWorkspace, | |
| 486 | + | }, | |
| 487 | + | { | |
| 488 | + | Key: "workspace.popups.open_in_new_pane", | |
| 489 | + | Type: "bool", | |
| 490 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OpenInNewPane), | |
| 491 | + | Description: "Open popups in workspace instead of blocking", | |
| 492 | + | Section: SectionWorkspace, | |
| 493 | + | }, | |
| 494 | + | { | |
| 495 | + | Key: "workspace.popups.follow_pane_context", | |
| 496 | + | Type: "bool", | |
| 497 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.FollowPaneContext), | |
| 498 | + | Description: "Popup placement follows parent pane context", | |
| 499 | + | Section: SectionWorkspace, | |
| 500 | + | }, | |
| 501 | + | { | |
| 502 | + | Key: "workspace.popups.enable_smart_detection", | |
| 503 | + | Type: "bool", | |
| 504 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.EnableSmartDetection), | |
| 505 | + | Description: "Use window properties to detect popup intent", | |
| 506 | + | Section: SectionWorkspace, | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | Key: "workspace.popups.oauth_auto_close", | |
| 510 | + | Type: "bool", | |
| 511 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OAuthAutoClose), | |
| 512 | + | Description: "Auto-close OAuth popups after success", | |
| 513 | + | Section: SectionWorkspace, | |
| 514 | + | }, | |
| 515 | + | // Styling | |
| 516 | + | { | |
| 517 | + | Key: "workspace.styling.border_width", | |
| 518 | + | Type: "int", | |
| 519 | + | Default: fmt.Sprintf("%d", defaults.Workspace.Styling.BorderWidth), | |
| 520 | + | Description: "Active pane border width in pixels", | |
| 521 | + | Range: ">=0", | |
| 522 | + | Section: SectionWorkspace, | |
| 523 | + | }, | |
| 524 | + | { | |
| 525 | + | Key: "workspace.styling.border_color", | |
| 526 | + | Type: "string", | |
| 527 | + | Default: defaults.Workspace.Styling.BorderColor, | |
| 528 | + | Description: "Active pane border color (CSS or @theme variable)", | |
| 529 | + | Section: SectionWorkspace, | |
| 530 | + | }, | |
| 531 | + | { | |
| 532 | + | Key: "workspace.styling.mode_border_width", | |
| 533 | + | Type: "int", | |
| 534 | + | Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeBorderWidth), | |
| 535 | + | Description: "Border width for modal mode indicators", | |
| 536 | + | Range: ">=0", | |
| 537 | + | Section: SectionWorkspace, | |
| 538 | + | }, | |
| 539 | + | { | |
| 540 | + | Key: "workspace.styling.pane_mode_color", | |
| 541 | + | Type: "string", | |
| 542 | + | Default: defaults.Workspace.Styling.PaneModeColor, | |
| 543 | + | Description: "Color for pane mode indicator", | |
| 544 | + | Section: SectionWorkspace, | |
| 545 | + | }, | |
| 546 | + | { | |
| 547 | + | Key: "workspace.styling.tab_mode_color", | |
| 548 | + | Type: "string", | |
| 549 | + | Default: defaults.Workspace.Styling.TabModeColor, | |
| 550 | + | Description: "Color for tab mode indicator", | |
| 551 | + | Section: SectionWorkspace, | |
| 552 | + | }, | |
| 553 | + | { | |
| 554 | + | Key: "workspace.styling.session_mode_color", | |
| 555 | + | Type: "string", | |
| 556 | + | Default: defaults.Workspace.Styling.SessionModeColor, | |
| 557 | + | Description: "Color for session mode indicator", | |
| 558 | + | Section: SectionWorkspace, | |
| 559 | + | }, | |
| 560 | + | { | |
| 561 | + | Key: "workspace.styling.resize_mode_color", | |
| 562 | + | Type: "string", | |
| 563 | + | Default: defaults.Workspace.Styling.ResizeModeColor, | |
| 564 | + | Description: "Color for resize mode indicator", | |
| 565 | + | Section: SectionWorkspace, | |
| 566 | + | }, | |
| 567 | + | { | |
| 568 | + | Key: "workspace.styling.mode_indicator_toaster_enabled", | |
| 569 | + | Type: "bool", | |
| 570 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled), | |
| 571 | + | Description: "Show toaster notification for modal modes", | |
| 572 | + | Section: SectionWorkspace, | |
| 573 | + | }, | |
| 574 | + | { | |
| 575 | + | Key: "workspace.styling.transition_duration", | |
| 576 | + | Type: "int", | |
| 577 | + | Default: fmt.Sprintf("%d", defaults.Workspace.Styling.TransitionDuration), | |
| 578 | + | Description: "Border animation duration in milliseconds", | |
| 579 | + | Range: ">=0", | |
| 580 | + | Section: SectionWorkspace, | |
| 581 | + | }, | |
| 582 | + | } | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | func (*SchemaProvider) getSessionKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 586 | + | return []entity.ConfigKeyInfo{ | |
| 587 | + | { | |
| 588 | + | Key: "session.auto_restore", | |
| 589 | + | Type: "bool", | |
| 590 | + | Default: fmt.Sprintf("%t", defaults.Session.AutoRestore), | |
| 591 | + | Description: "Automatically restore last session on startup", | |
| 592 | + | Section: SectionSession, | |
| 593 | + | }, | |
| 594 | + | { | |
| 595 | + | Key: "session.snapshot_interval_ms", | |
| 596 | + | Type: "int", | |
| 597 | + | Default: fmt.Sprintf("%d", defaults.Session.SnapshotIntervalMs), | |
| 598 | + | Description: "Minimum interval between session snapshots", | |
| 599 | + | Range: ">=0", | |
| 600 | + | Section: SectionSession, | |
| 601 | + | }, | |
| 602 | + | { | |
| 603 | + | Key: "session.max_exited_sessions", | |
| 604 | + | Type: "int", | |
| 605 | + | Default: fmt.Sprintf("%d", defaults.Session.MaxExitedSessions), | |
| 606 | + | Description: "Maximum number of exited sessions to keep", | |
| 607 | + | Range: ">=0", | |
| 608 | + | Section: SectionSession, | |
| 609 | + | }, | |
| 610 | + | { | |
| 611 | + | Key: "session.max_exited_session_age_days", | |
| 612 | + | Type: "int", | |
| 613 | + | Default: fmt.Sprintf("%d", defaults.Session.MaxExitedSessionAgeDays), | |
| 614 | + | Description: "Maximum age of exited sessions in days", | |
| 615 | + | Range: ">=0", | |
| 616 | + | Section: SectionSession, | |
| 617 | + | }, | |
| 618 | + | { | |
| 619 | + | Key: "session.session_mode.activation_shortcut", | |
| 620 | + | Type: "string", | |
| 621 | + | Default: defaults.Session.SessionMode.ActivationShortcut, | |
| 622 | + | Description: "Shortcut to enter session mode", | |
| 623 | + | Section: SectionSession, | |
| 624 | + | }, | |
| 625 | + | { | |
| 626 | + | Key: "session.session_mode.timeout_ms", | |
| 627 | + | Type: "int", | |
| 628 | + | Default: fmt.Sprintf("%d", defaults.Session.SessionMode.TimeoutMilliseconds), | |
| 629 | + | Description: "Session mode timeout in milliseconds", | |
| 630 | + | Range: ">=0", | |
| 631 | + | Section: SectionSession, | |
| 632 | + | }, | |
| 633 | + | { | |
| 634 | + | Key: "session.session_mode.actions.<action>", | |
| 635 | + | Type: "[]string", | |
| 636 | + | Default: "(see defaults)", | |
| 637 | + | Description: "Key bindings for session mode actions", | |
| 638 | + | Section: SectionSession, | |
| 639 | + | }, | |
| 640 | + | } | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 644 | + | return []entity.ConfigKeyInfo{ | |
| 645 | + | { | |
| 646 | + | Key: "omnibox.initial_behavior", | |
| 647 | + | Type: "string", | |
| 648 | + | Default: defaults.Omnibox.InitialBehavior, | |
| 649 | + | Description: "What to show when omnibox opens with empty input", | |
| 650 | + | Values: []string{"recent", "most_visited", "none"}, | |
| 651 | + | Section: SectionOmnibox, | |
| 652 | + | }, | |
| 653 | + | { | |
| 654 | + | Key: "omnibox.auto_open_on_new_pane", | |
| 655 | + | Type: "bool", | |
| 656 | + | Default: fmt.Sprintf("%t", defaults.Omnibox.AutoOpenOnNewPane), | |
| 657 | + | Description: "Auto-open omnibox when creating new pane", | |
| 658 | + | Section: SectionOmnibox, | |
| 659 | + | }, | |
| 660 | + | } | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | func (*SchemaProvider) getContentFilteringKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 664 | + | return []entity.ConfigKeyInfo{ | |
| 665 | + | { | |
| 666 | + | Key: "content_filtering.enabled", | |
| 667 | + | Type: "bool", | |
| 668 | + | Default: fmt.Sprintf("%t", defaults.ContentFiltering.Enabled), | |
| 669 | + | Description: "Enable ad blocking and content filtering", | |
| 670 | + | Section: SectionContentFiltering, | |
| 671 | + | }, | |
| 672 | + | { | |
| 673 | + | Key: "content_filtering.auto_update", | |
| 674 | + | Type: "bool", | |
| 675 | + | Default: fmt.Sprintf("%t", defaults.ContentFiltering.AutoUpdate), | |
| 676 | + | Description: "Auto-update filter lists from GitHub", | |
| 677 | + | Section: SectionContentFiltering, | |
| 678 | + | }, | |
| 679 | + | } | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 683 | + | return []entity.ConfigKeyInfo{ | |
| 684 | + | { | |
| 685 | + | Key: "rendering.mode", | |
| 686 | + | Type: "string", | |
| 687 | + | Default: string(defaults.Rendering.Mode), | |
| 688 | + | Description: "GPU/CPU rendering selection for WebKit", | |
| 689 | + | Values: []string{"auto", "gpu", "cpu"}, | |
| 690 | + | Section: SectionRendering, | |
| 691 | + | }, | |
| 692 | + | { | |
| 693 | + | Key: "rendering.gsk_renderer", | |
| 694 | + | Type: "string", | |
| 695 | + | Default: string(defaults.Rendering.GSKRenderer), | |
| 696 | + | Description: "GTK Scene Kit renderer selection", | |
| 697 | + | Values: []string{"auto", "opengl", "vulkan", "cairo"}, | |
| 698 | + | Section: SectionRendering, | |
| 699 | + | }, | |
| 700 | + | { | |
| 701 | + | Key: "rendering.disable_dmabuf_renderer", | |
| 702 | + | Type: "bool", | |
| 703 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DisableDMABufRenderer), | |
| 704 | + | Description: "Disable DMA-BUF accelerated renderer", | |
| 705 | + | Section: SectionRendering, | |
| 706 | + | }, | |
| 707 | + | { | |
| 708 | + | Key: "rendering.force_compositing_mode", | |
| 709 | + | Type: "bool", | |
| 710 | + | Default: fmt.Sprintf("%t", defaults.Rendering.ForceCompositingMode), | |
| 711 | + | Description: "Force accelerated compositing", | |
| 712 | + | Section: SectionRendering, | |
| 713 | + | }, | |
| 714 | + | { | |
| 715 | + | Key: "rendering.disable_compositing_mode", | |
| 716 | + | Type: "bool", | |
| 717 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DisableCompositingMode), | |
| 718 | + | Description: "Disable accelerated compositing", | |
| 719 | + | Section: SectionRendering, | |
| 720 | + | }, | |
| 721 | + | { | |
| 722 | + | Key: "rendering.disable_mipmaps", | |
| 723 | + | Type: "bool", | |
| 724 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DisableMipmaps), | |
| 725 | + | Description: "Disable mipmap generation", | |
| 726 | + | Section: SectionRendering, | |
| 727 | + | }, | |
| 728 | + | { | |
| 729 | + | Key: "rendering.prefer_gl", | |
| 730 | + | Type: "bool", | |
| 731 | + | Default: fmt.Sprintf("%t", defaults.Rendering.PreferGL), | |
| 732 | + | Description: "Force OpenGL over GLES", | |
| 733 | + | Section: SectionRendering, | |
| 734 | + | }, | |
| 735 | + | { | |
| 736 | + | Key: "rendering.draw_compositing_indicators", | |
| 737 | + | Type: "bool", | |
| 738 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DrawCompositingIndicators), | |
| 739 | + | Description: "Show compositing layer borders", | |
| 740 | + | Section: SectionRendering, | |
| 741 | + | }, | |
| 742 | + | { | |
| 743 | + | Key: "rendering.show_fps", | |
| 744 | + | Type: "bool", | |
| 745 | + | Default: fmt.Sprintf("%t", defaults.Rendering.ShowFPS), | |
| 746 | + | Description: "Display WebKit FPS counter", | |
| 747 | + | Section: SectionRendering, | |
| 748 | + | }, | |
| 749 | + | { | |
| 750 | + | Key: "rendering.sample_memory", | |
| 751 | + | Type: "bool", | |
| 752 | + | Default: fmt.Sprintf("%t", defaults.Rendering.SampleMemory), | |
| 753 | + | Description: "Enable memory sampling", | |
| 754 | + | Section: SectionRendering, | |
| 755 | + | }, | |
| 756 | + | { | |
| 757 | + | Key: "rendering.debug_frames", | |
| 758 | + | Type: "bool", | |
| 759 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DebugFrames), | |
| 760 | + | Description: "Enable frame timing debug output", | |
| 761 | + | Section: SectionRendering, | |
| 762 | + | }, | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 767 | + | return []entity.ConfigKeyInfo{ | |
| 768 | + | { | |
| 769 | + | Key: "media.hardware_decoding", | |
| 770 | + | Type: "string", | |
| 771 | + | Default: string(defaults.Media.HardwareDecodingMode), | |
| 772 | + | Description: "Hardware vs software video decoding", | |
| 773 | + | Values: []string{"auto", "force", "disable"}, | |
| 774 | + | Section: SectionMedia, | |
| 775 | + | }, | |
| 776 | + | { | |
| 777 | + | Key: "media.gl_rendering_mode", | |
| 778 | + | Type: "string", | |
| 779 | + | Default: string(defaults.Media.GLRenderingMode), | |
| 780 | + | Description: "OpenGL API selection for video rendering", | |
| 781 | + | Values: []string{"auto", "gles2", "gl3", "none"}, | |
| 782 | + | Section: SectionMedia, | |
| 783 | + | }, | |
| 784 | + | { | |
| 785 | + | Key: "media.prefer_av1", | |
| 786 | + | Type: "bool", | |
| 787 | + | Default: fmt.Sprintf("%t", defaults.Media.PreferAV1), | |
| 788 | + | Description: "Prioritize AV1 codec when available", | |
| 789 | + | Section: SectionMedia, | |
| 790 | + | }, | |
| 791 | + | { | |
| 792 | + | Key: "media.show_diagnostics", | |
| 793 | + | Type: "bool", | |
| 794 | + | Default: fmt.Sprintf("%t", defaults.Media.ShowDiagnosticsOnStartup), | |
| 795 | + | Description: "Show media capability warnings at startup", | |
| 796 | + | Section: SectionMedia, | |
| 797 | + | }, | |
| 798 | + | { | |
| 799 | + | Key: "media.force_vsync", | |
| 800 | + | Type: "bool", | |
| 801 | + | Default: fmt.Sprintf("%t", defaults.Media.ForceVSync), | |
| 802 | + | Description: "Force vertical sync for video playback", | |
| 803 | + | Section: SectionMedia, | |
| 804 | + | }, | |
| 805 | + | { | |
| 806 | + | Key: "media.gstreamer_debug_level", | |
| 807 | + | Type: "int", | |
| 808 | + | Default: fmt.Sprintf("%d", defaults.Media.GStreamerDebugLevel), | |
| 809 | + | Description: "GStreamer debug verbosity (0=off, 1-5)", | |
| 810 | + | Range: "0-5", | |
| 811 | + | Section: SectionMedia, | |
| 812 | + | }, | |
| 813 | + | } | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | func (*SchemaProvider) getUpdateKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 817 | + | return []entity.ConfigKeyInfo{ | |
| 818 | + | { | |
| 819 | + | Key: "update.enable_on_startup", | |
| 820 | + | Type: "bool", | |
| 821 | + | Default: fmt.Sprintf("%t", defaults.Update.EnableOnStartup), | |
| 822 | + | Description: "Check for updates on browser startup", | |
| 823 | + | Section: SectionUpdate, | |
| 824 | + | }, | |
| 825 | + | { | |
| 826 | + | Key: "update.auto_download", | |
| 827 | + | Type: "bool", | |
| 828 | + | Default: fmt.Sprintf("%t", defaults.Update.AutoDownload), | |
| 829 | + | Description: "Auto-download updates in background", | |
| 830 | + | Section: SectionUpdate, | |
| 831 | + | }, | |
| 832 | + | { | |
| 833 | + | Key: "update.notify_on_new_settings", | |
| 834 | + | Type: "bool", | |
| 835 | + | Default: fmt.Sprintf("%t", defaults.Update.NotifyOnNewSettings), | |
| 836 | + | Description: "Show toast when new config settings available", | |
| 837 | + | Section: SectionUpdate, | |
| 838 | + | }, | |
| 839 | + | } | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 843 | + | return []entity.ConfigKeyInfo{ | |
| 844 | + | { | |
| 845 | + | Key: "debug.enable_devtools", | |
| 846 | + | Type: "bool", | |
| 847 | + | Default: fmt.Sprintf("%t", defaults.Debug.EnableDevTools), | |
| 848 | + | Description: "Enable browser developer tools (F12)", | |
| 849 | + | Section: SectionDebug, | |
| 850 | + | }, | |
| 851 | + | } | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 855 | + | return []entity.ConfigKeyInfo{ | |
| 856 | + | { | |
| 857 | + | Key: "performance.zoom_cache_size", | |
| 858 | + | Type: "int", | |
| 859 | + | Default: fmt.Sprintf("%d", defaults.Performance.ZoomCacheSize), | |
| 860 | + | Description: "Number of domain zoom levels to cache", | |
| 861 | + | Range: ">=0", | |
| 862 | + | Section: SectionPerformance, | |
| 863 | + | }, | |
| 864 | + | { | |
| 865 | + | Key: "performance.webview_pool_prewarm_count", | |
| 866 | + | Type: "int", | |
| 867 | + | Default: fmt.Sprintf("%d", defaults.Performance.WebViewPoolPrewarmCount), | |
| 868 | + | Description: "WebViews to pre-create at startup", | |
| 869 | + | Range: ">=0", | |
| 870 | + | Section: SectionPerformance, | |
| 871 | + | }, | |
| 872 | + | } | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | func (*SchemaProvider) getRuntimeKeys(_ *Config) []entity.ConfigKeyInfo { | |
| 876 | + | return []entity.ConfigKeyInfo{ | |
| 877 | + | { | |
| 878 | + | Key: "runtime.prefix", | |
| 879 | + | Type: "string", | |
| 880 | + | Default: "(empty)", | |
| 881 | + | Description: "Custom runtime prefix for WebKitGTK/GTK", | |
| 882 | + | Section: SectionRuntime, | |
| 883 | + | }, | |
| 884 | + | } | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | func (*SchemaProvider) getDatabaseKeys() []entity.ConfigKeyInfo { | |
| 888 | + | // Get actual default path for display | |
| 889 | + | dbPath := "$XDG_DATA_HOME/" + appName + "/" + databaseName | |
| 890 | + | if path, err := GetDatabaseFile(); err == nil { | |
| 891 | + | dbPath = path | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | return []entity.ConfigKeyInfo{ | |
| 895 | + | { | |
| 896 | + | Key: "database.path", | |
| 897 | + | Type: "string", | |
| 898 | + | Default: dbPath, | |
| 899 | + | Description: "Path to SQLite database file", | |
| 900 | + | Section: SectionDatabase, | |
| 901 | + | }, | |
| 902 | + | } | |
| 903 | + | } | |
| @@ -0,0 +1,903 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Section names for grouping config keys. | |||
| 10 | + | const ( | |||
| 11 | + | SectionAppearance = "Appearance" | |||
| 12 | + | SectionLogging = "Logging" | |||
| 13 | + | SectionHistory = "History" | |||
| 14 | + | SectionWorkspace = "Workspace" | |||
| 15 | + | SectionSession = "Session" | |||
| 16 | + | SectionOmnibox = "Omnibox" | |||
| 17 | + | SectionContentFiltering = "Content Filtering" | |||
| 18 | + | SectionRendering = "Rendering" | |||
| 19 | + | SectionMedia = "Media" | |||
| 20 | + | SectionUpdate = "Update" | |||
| 21 | + | SectionDmenu = "Dmenu" | |||
| 22 | + | SectionDebug = "Debug" | |||
| 23 | + | SectionPerformance = "Performance" | |||
| 24 | + | SectionRuntime = "Runtime" | |||
| 25 | + | SectionDatabase = "Database" | |||
| 26 | + | SectionSearch = "Search" | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // SchemaProvider implements port.ConfigSchemaProvider. | |||
| 30 | + | type SchemaProvider struct{} | |||
| 31 | + | ||||
| 32 | + | // NewSchemaProvider creates a new SchemaProvider. | |||
| 33 | + | func NewSchemaProvider() *SchemaProvider { | |||
| 34 | + | return &SchemaProvider{} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // GetSchema returns all configuration keys with their metadata. | |||
| 38 | + | func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |||
| 39 | + | defaults := DefaultConfig() | |||
| 40 | + | ||||
| 41 | + | keys := make([]entity.ConfigKeyInfo, 0, 100) | |||
| 42 | + | ||||
| 43 | + | // Appearance section | |||
| 44 | + | keys = append(keys, p.getAppearanceKeys(defaults)...) | |||
| 45 | + | ||||
| 46 | + | // Logging section | |||
| 47 | + | keys = append(keys, p.getLoggingKeys(defaults)...) | |||
| 48 | + | ||||
| 49 | + | // History section | |||
| 50 | + | keys = append(keys, p.getHistoryKeys(defaults)...) | |||
| 51 | + | ||||
| 52 | + | // Search section | |||
| 53 | + | keys = append(keys, p.getSearchKeys(defaults)...) | |||
| 54 | + | ||||
| 55 | + | // Dmenu section | |||
| 56 | + | keys = append(keys, p.getDmenuKeys(defaults)...) | |||
| 57 | + | ||||
| 58 | + | // Workspace section | |||
| 59 | + | keys = append(keys, p.getWorkspaceKeys(defaults)...) | |||
| 60 | + | ||||
| 61 | + | // Session section | |||
| 62 | + | keys = append(keys, p.getSessionKeys(defaults)...) | |||
| 63 | + | ||||
| 64 | + | // Omnibox section | |||
| 65 | + | keys = append(keys, p.getOmniboxKeys(defaults)...) | |||
| 66 | + | ||||
| 67 | + | // Content Filtering section | |||
| 68 | + | keys = append(keys, p.getContentFilteringKeys(defaults)...) | |||
| 69 | + | ||||
| 70 | + | // Rendering section | |||
| 71 | + | keys = append(keys, p.getRenderingKeys(defaults)...) | |||
| 72 | + | ||||
| 73 | + | // Media section | |||
| 74 | + | keys = append(keys, p.getMediaKeys(defaults)...) | |||
| 75 | + | ||||
| 76 | + | // Update section | |||
| 77 | + | keys = append(keys, p.getUpdateKeys(defaults)...) | |||
| 78 | + | ||||
| 79 | + | // Debug section | |||
| 80 | + | keys = append(keys, p.getDebugKeys(defaults)...) | |||
| 81 | + | ||||
| 82 | + | // Performance section | |||
| 83 | + | keys = append(keys, p.getPerformanceKeys(defaults)...) | |||
| 84 | + | ||||
| 85 | + | // Runtime section | |||
| 86 | + | keys = append(keys, p.getRuntimeKeys(defaults)...) | |||
| 87 | + | ||||
| 88 | + | // Database section | |||
| 89 | + | keys = append(keys, p.getDatabaseKeys()...) | |||
| 90 | + | ||||
| 91 | + | return keys | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (*SchemaProvider) getAppearanceKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 95 | + | return []entity.ConfigKeyInfo{ | |||
| 96 | + | { | |||
| 97 | + | Key: "appearance.color_scheme", | |||
| 98 | + | Type: "string", | |||
| 99 | + | Default: defaults.Appearance.ColorScheme, | |||
| 100 | + | Description: "Theme preference (follows system by default)", | |||
| 101 | + | Values: []string{"default", "prefer-dark", "prefer-light"}, | |||
| 102 | + | Section: SectionAppearance, | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | Key: "appearance.default_font_size", | |||
| 106 | + | Type: "int", | |||
| 107 | + | Default: fmt.Sprintf("%d", defaults.Appearance.DefaultFontSize), | |||
| 108 | + | Description: "Default font size in CSS pixels", | |||
| 109 | + | Range: "1-72", | |||
| 110 | + | Section: SectionAppearance, | |||
| 111 | + | }, | |||
| 112 | + | { | |||
| 113 | + | Key: "appearance.sans_font", | |||
| 114 | + | Type: "string", | |||
| 115 | + | Default: defaults.Appearance.SansFont, | |||
| 116 | + | Description: "Sans-serif font family for web pages", | |||
| 117 | + | Section: SectionAppearance, | |||
| 118 | + | }, | |||
| 119 | + | { | |||
| 120 | + | Key: "appearance.serif_font", | |||
| 121 | + | Type: "string", | |||
| 122 | + | Default: defaults.Appearance.SerifFont, | |||
| 123 | + | Description: "Serif font family for web pages", | |||
| 124 | + | Section: SectionAppearance, | |||
| 125 | + | }, | |||
| 126 | + | { | |||
| 127 | + | Key: "appearance.monospace_font", | |||
| 128 | + | Type: "string", | |||
| 129 | + | Default: defaults.Appearance.MonospaceFont, | |||
| 130 | + | Description: "Monospace font family for code", | |||
| 131 | + | Section: SectionAppearance, | |||
| 132 | + | }, | |||
| 133 | + | { | |||
| 134 | + | Key: "default_webpage_zoom", | |||
| 135 | + | Type: "float64", | |||
| 136 | + | Default: fmt.Sprintf("%.1f", defaults.DefaultWebpageZoom), | |||
| 137 | + | Description: "Default zoom level for web pages (1.0 = 100%%)", | |||
| 138 | + | Range: "0.1-5.0", | |||
| 139 | + | Section: SectionAppearance, | |||
| 140 | + | }, | |||
| 141 | + | { | |||
| 142 | + | Key: "default_ui_scale", | |||
| 143 | + | Type: "float64", | |||
| 144 | + | Default: fmt.Sprintf("%.1f", defaults.DefaultUIScale), | |||
| 145 | + | Description: "UI scale multiplier for GTK widgets", | |||
| 146 | + | Range: "0.5-3.0", | |||
| 147 | + | Section: SectionAppearance, | |||
| 148 | + | }, | |||
| 149 | + | { | |||
| 150 | + | Key: "appearance.light_palette.*", | |||
| 151 | + | Type: "string", | |||
| 152 | + | Default: "(hex colors)", | |||
| 153 | + | Description: "Light theme color palette (background, surface, surface_variant, text, muted, accent, border)", | |||
| 154 | + | Section: SectionAppearance, | |||
| 155 | + | }, | |||
| 156 | + | { | |||
| 157 | + | Key: "appearance.dark_palette.*", | |||
| 158 | + | Type: "string", | |||
| 159 | + | Default: "(hex colors)", | |||
| 160 | + | Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)", | |||
| 161 | + | Section: SectionAppearance, | |||
| 162 | + | }, | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (*SchemaProvider) getLoggingKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 167 | + | return []entity.ConfigKeyInfo{ | |||
| 168 | + | { | |||
| 169 | + | Key: "logging.level", | |||
| 170 | + | Type: "string", | |||
| 171 | + | Default: defaults.Logging.Level, | |||
| 172 | + | Description: "Log verbosity level", | |||
| 173 | + | Values: []string{"trace", "debug", "info", "warn", "error", "fatal"}, | |||
| 174 | + | Section: SectionLogging, | |||
| 175 | + | }, | |||
| 176 | + | { | |||
| 177 | + | Key: "logging.format", | |||
| 178 | + | Type: "string", | |||
| 179 | + | Default: defaults.Logging.Format, | |||
| 180 | + | Description: "Log output format", | |||
| 181 | + | Values: []string{"text", "json", "console"}, | |||
| 182 | + | Section: SectionLogging, | |||
| 183 | + | }, | |||
| 184 | + | { | |||
| 185 | + | Key: "logging.max_age", | |||
| 186 | + | Type: "int", | |||
| 187 | + | Default: fmt.Sprintf("%d", defaults.Logging.MaxAge), | |||
| 188 | + | Description: "Maximum age of log files in days", | |||
| 189 | + | Range: ">=0", | |||
| 190 | + | Section: SectionLogging, | |||
| 191 | + | }, | |||
| 192 | + | { | |||
| 193 | + | Key: "logging.log_dir", | |||
| 194 | + | Type: "string", | |||
| 195 | + | Default: defaults.Logging.LogDir, | |||
| 196 | + | Description: "Directory for log files", | |||
| 197 | + | Section: SectionLogging, | |||
| 198 | + | }, | |||
| 199 | + | { | |||
| 200 | + | Key: "logging.enable_file_log", | |||
| 201 | + | Type: "bool", | |||
| 202 | + | Default: fmt.Sprintf("%t", defaults.Logging.EnableFileLog), | |||
| 203 | + | Description: "Enable logging to file", | |||
| 204 | + | Section: SectionLogging, | |||
| 205 | + | }, | |||
| 206 | + | { | |||
| 207 | + | Key: "logging.capture_console", | |||
| 208 | + | Type: "bool", | |||
| 209 | + | Default: fmt.Sprintf("%t", defaults.Logging.CaptureConsole), | |||
| 210 | + | Description: "Capture browser console messages to log", | |||
| 211 | + | Section: SectionLogging, | |||
| 212 | + | }, | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (*SchemaProvider) getHistoryKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 217 | + | return []entity.ConfigKeyInfo{ | |||
| 218 | + | { | |||
| 219 | + | Key: "history.max_entries", | |||
| 220 | + | Type: "int", | |||
| 221 | + | Default: fmt.Sprintf("%d", defaults.History.MaxEntries), | |||
| 222 | + | Description: "Maximum number of history entries to keep", | |||
| 223 | + | Range: ">=0", | |||
| 224 | + | Section: SectionHistory, | |||
| 225 | + | }, | |||
| 226 | + | { | |||
| 227 | + | Key: "history.retention_period_days", | |||
| 228 | + | Type: "int", | |||
| 229 | + | Default: fmt.Sprintf("%d", defaults.History.RetentionPeriodDays), | |||
| 230 | + | Description: "Number of days to retain history", | |||
| 231 | + | Range: ">=0", | |||
| 232 | + | Section: SectionHistory, | |||
| 233 | + | }, | |||
| 234 | + | { | |||
| 235 | + | Key: "history.cleanup_interval_days", | |||
| 236 | + | Type: "int", | |||
| 237 | + | Default: fmt.Sprintf("%d", defaults.History.CleanupIntervalDays), | |||
| 238 | + | Description: "Days between automatic history cleanup", | |||
| 239 | + | Range: ">=0", | |||
| 240 | + | Section: SectionHistory, | |||
| 241 | + | }, | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 246 | + | return []entity.ConfigKeyInfo{ | |||
| 247 | + | { | |||
| 248 | + | Key: "default_search_engine", | |||
| 249 | + | Type: "string", | |||
| 250 | + | Default: defaults.DefaultSearchEngine, | |||
| 251 | + | Description: "Default search engine URL (must contain %s placeholder)", | |||
| 252 | + | Section: SectionSearch, | |||
| 253 | + | }, | |||
| 254 | + | { | |||
| 255 | + | Key: "search_shortcuts.<key>", | |||
| 256 | + | Type: "object", | |||
| 257 | + | Default: "(see defaults)", | |||
| 258 | + | Description: "Search shortcuts map with url and description fields", | |||
| 259 | + | Section: SectionSearch, | |||
| 260 | + | }, | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 265 | + | return []entity.ConfigKeyInfo{ | |||
| 266 | + | { | |||
| 267 | + | Key: "dmenu.max_history_items", | |||
| 268 | + | Type: "int", | |||
| 269 | + | Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems), | |||
| 270 | + | Description: "Maximum history items shown in dmenu", | |||
| 271 | + | Range: ">=0", | |||
| 272 | + | Section: SectionDmenu, | |||
| 273 | + | }, | |||
| 274 | + | { | |||
| 275 | + | Key: "dmenu.show_visit_count", | |||
| 276 | + | Type: "bool", | |||
| 277 | + | Default: fmt.Sprintf("%t", defaults.Dmenu.ShowVisitCount), | |||
| 278 | + | Description: "Show visit count in dmenu entries", | |||
| 279 | + | Section: SectionDmenu, | |||
| 280 | + | }, | |||
| 281 | + | { | |||
| 282 | + | Key: "dmenu.show_last_visited", | |||
| 283 | + | Type: "bool", | |||
| 284 | + | Default: fmt.Sprintf("%t", defaults.Dmenu.ShowLastVisited), | |||
| 285 | + | Description: "Show last visited date in dmenu entries", | |||
| 286 | + | Section: SectionDmenu, | |||
| 287 | + | }, | |||
| 288 | + | { | |||
| 289 | + | Key: "dmenu.history_prefix", | |||
| 290 | + | Type: "string", | |||
| 291 | + | Default: defaults.Dmenu.HistoryPrefix, | |||
| 292 | + | Description: "Prefix for history entries in dmenu", | |||
| 293 | + | Section: SectionDmenu, | |||
| 294 | + | }, | |||
| 295 | + | { | |||
| 296 | + | Key: "dmenu.shortcut_prefix", | |||
| 297 | + | Type: "string", | |||
| 298 | + | Default: defaults.Dmenu.ShortcutPrefix, | |||
| 299 | + | Description: "Prefix for search shortcuts in dmenu", | |||
| 300 | + | Section: SectionDmenu, | |||
| 301 | + | }, | |||
| 302 | + | { | |||
| 303 | + | Key: "dmenu.url_prefix", | |||
| 304 | + | Type: "string", | |||
| 305 | + | Default: defaults.Dmenu.URLPrefix, | |||
| 306 | + | Description: "Prefix for URL entries in dmenu", | |||
| 307 | + | Section: SectionDmenu, | |||
| 308 | + | }, | |||
| 309 | + | { | |||
| 310 | + | Key: "dmenu.date_format", | |||
| 311 | + | Type: "string", | |||
| 312 | + | Default: defaults.Dmenu.DateFormat, | |||
| 313 | + | Description: "Go time format for dates in dmenu", | |||
| 314 | + | Section: SectionDmenu, | |||
| 315 | + | }, | |||
| 316 | + | { | |||
| 317 | + | Key: "dmenu.sort_by_visit_count", | |||
| 318 | + | Type: "bool", | |||
| 319 | + | Default: fmt.Sprintf("%t", defaults.Dmenu.SortByVisitCount), | |||
| 320 | + | Description: "Sort dmenu entries by visit count", | |||
| 321 | + | Section: SectionDmenu, | |||
| 322 | + | }, | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 327 | + | return []entity.ConfigKeyInfo{ | |||
| 328 | + | { | |||
| 329 | + | Key: "workspace.new_pane_url", | |||
| 330 | + | Type: "string", | |||
| 331 | + | Default: defaults.Workspace.NewPaneURL, | |||
| 332 | + | Description: "URL loaded when creating a new pane", | |||
| 333 | + | Section: SectionWorkspace, | |||
| 334 | + | }, | |||
| 335 | + | { | |||
| 336 | + | Key: "workspace.tab_bar_position", | |||
| 337 | + | Type: "string", | |||
| 338 | + | Default: defaults.Workspace.TabBarPosition, | |||
| 339 | + | Description: "Position of the tab bar", | |||
| 340 | + | Values: []string{"top", "bottom"}, | |||
| 341 | + | Section: SectionWorkspace, | |||
| 342 | + | }, | |||
| 343 | + | { | |||
| 344 | + | Key: "workspace.hide_tab_bar_when_single_tab", | |||
| 345 | + | Type: "bool", | |||
| 346 | + | Default: fmt.Sprintf("%t", defaults.Workspace.HideTabBarWhenSingleTab), | |||
| 347 | + | Description: "Hide tab bar when only one tab exists", | |||
| 348 | + | Section: SectionWorkspace, | |||
| 349 | + | }, | |||
| 350 | + | { | |||
| 351 | + | Key: "workspace.switch_to_tab_on_move", | |||
| 352 | + | Type: "bool", | |||
| 353 | + | Default: fmt.Sprintf("%t", defaults.Workspace.SwitchToTabOnMove), | |||
| 354 | + | Description: "Switch focus to tab when moving pane to it", | |||
| 355 | + | Section: SectionWorkspace, | |||
| 356 | + | }, | |||
| 357 | + | // Pane mode | |||
| 358 | + | { | |||
| 359 | + | Key: "workspace.pane_mode.activation_shortcut", | |||
| 360 | + | Type: "string", | |||
| 361 | + | Default: defaults.Workspace.PaneMode.ActivationShortcut, | |||
| 362 | + | Description: "Shortcut to enter pane mode", | |||
| 363 | + | Section: SectionWorkspace, | |||
| 364 | + | }, | |||
| 365 | + | { | |||
| 366 | + | Key: "workspace.pane_mode.timeout_ms", | |||
| 367 | + | Type: "int", | |||
| 368 | + | Default: fmt.Sprintf("%d", defaults.Workspace.PaneMode.TimeoutMilliseconds), | |||
| 369 | + | Description: "Pane mode timeout in milliseconds (0 = no timeout)", | |||
| 370 | + | Range: ">=0", | |||
| 371 | + | Section: SectionWorkspace, | |||
| 372 | + | }, | |||
| 373 | + | { | |||
| 374 | + | Key: "workspace.pane_mode.actions.<action>", | |||
| 375 | + | Type: "[]string", | |||
| 376 | + | Default: "(see defaults)", | |||
| 377 | + | Description: "Key bindings for pane mode actions", | |||
| 378 | + | Section: SectionWorkspace, | |||
| 379 | + | }, | |||
| 380 | + | // Tab mode | |||
| 381 | + | { | |||
| 382 | + | Key: "workspace.tab_mode.activation_shortcut", | |||
| 383 | + | Type: "string", | |||
| 384 | + | Default: defaults.Workspace.TabMode.ActivationShortcut, | |||
| 385 | + | Description: "Shortcut to enter tab mode", | |||
| 386 | + | Section: SectionWorkspace, | |||
| 387 | + | }, | |||
| 388 | + | { | |||
| 389 | + | Key: "workspace.tab_mode.timeout_ms", | |||
| 390 | + | Type: "int", | |||
| 391 | + | Default: fmt.Sprintf("%d", defaults.Workspace.TabMode.TimeoutMilliseconds), | |||
| 392 | + | Description: "Tab mode timeout in milliseconds (0 = no timeout)", | |||
| 393 | + | Range: ">=0", | |||
| 394 | + | Section: SectionWorkspace, | |||
| 395 | + | }, | |||
| 396 | + | { | |||
| 397 | + | Key: "workspace.tab_mode.actions.<action>", | |||
| 398 | + | Type: "[]string", | |||
| 399 | + | Default: "(see defaults)", | |||
| 400 | + | Description: "Key bindings for tab mode actions", | |||
| 401 | + | Section: SectionWorkspace, | |||
| 402 | + | }, | |||
| 403 | + | // Resize mode | |||
| 404 | + | { | |||
| 405 | + | Key: "workspace.resize_mode.activation_shortcut", | |||
| 406 | + | Type: "string", | |||
| 407 | + | Default: defaults.Workspace.ResizeMode.ActivationShortcut, | |||
| 408 | + | Description: "Shortcut to enter resize mode", | |||
| 409 | + | Section: SectionWorkspace, | |||
| 410 | + | }, | |||
| 411 | + | { | |||
| 412 | + | Key: "workspace.resize_mode.timeout_ms", | |||
| 413 | + | Type: "int", | |||
| 414 | + | Default: fmt.Sprintf("%d", defaults.Workspace.ResizeMode.TimeoutMilliseconds), | |||
| 415 | + | Description: "Resize mode timeout in milliseconds (0 = no timeout)", | |||
| 416 | + | Range: ">=0", | |||
| 417 | + | Section: SectionWorkspace, | |||
| 418 | + | }, | |||
| 419 | + | { | |||
| 420 | + | Key: "workspace.resize_mode.step_percent", | |||
| 421 | + | Type: "float64", | |||
| 422 | + | Default: fmt.Sprintf("%.1f", defaults.Workspace.ResizeMode.StepPercent), | |||
| 423 | + | Description: "Percentage to resize panes per step", | |||
| 424 | + | Section: SectionWorkspace, | |||
| 425 | + | }, | |||
| 426 | + | { | |||
| 427 | + | Key: "workspace.resize_mode.min_pane_percent", | |||
| 428 | + | Type: "float64", | |||
| 429 | + | Default: fmt.Sprintf("%.1f", defaults.Workspace.ResizeMode.MinPanePercent), | |||
| 430 | + | Description: "Minimum pane size as percentage", | |||
| 431 | + | Section: SectionWorkspace, | |||
| 432 | + | }, | |||
| 433 | + | { | |||
| 434 | + | Key: "workspace.resize_mode.actions.<action>", | |||
| 435 | + | Type: "[]string", | |||
| 436 | + | Default: "(see defaults)", | |||
| 437 | + | Description: "Key bindings for resize mode actions", | |||
| 438 | + | Section: SectionWorkspace, | |||
| 439 | + | }, | |||
| 440 | + | // Global shortcuts | |||
| 441 | + | { | |||
| 442 | + | Key: "workspace.shortcuts.close_pane", | |||
| 443 | + | Type: "string", | |||
| 444 | + | Default: defaults.Workspace.Shortcuts.ClosePane, | |||
| 445 | + | Description: "Global shortcut to close current pane", | |||
| 446 | + | Section: SectionWorkspace, | |||
| 447 | + | }, | |||
| 448 | + | { | |||
| 449 | + | Key: "workspace.shortcuts.next_tab", | |||
| 450 | + | Type: "string", | |||
| 451 | + | Default: defaults.Workspace.Shortcuts.NextTab, | |||
| 452 | + | Description: "Global shortcut to switch to next tab", | |||
| 453 | + | Section: SectionWorkspace, | |||
| 454 | + | }, | |||
| 455 | + | { | |||
| 456 | + | Key: "workspace.shortcuts.previous_tab", | |||
| 457 | + | Type: "string", | |||
| 458 | + | Default: defaults.Workspace.Shortcuts.PreviousTab, | |||
| 459 | + | Description: "Global shortcut to switch to previous tab", | |||
| 460 | + | Section: SectionWorkspace, | |||
| 461 | + | }, | |||
| 462 | + | // Popups | |||
| 463 | + | { | |||
| 464 | + | Key: "workspace.popups.behavior", | |||
| 465 | + | Type: "string", | |||
| 466 | + | Default: string(defaults.Workspace.Popups.Behavior), | |||
| 467 | + | Description: "How to handle JavaScript popup windows", | |||
| 468 | + | Values: []string{"split", "stacked", "tabbed", "windowed"}, | |||
| 469 | + | Section: SectionWorkspace, | |||
| 470 | + | }, | |||
| 471 | + | { | |||
| 472 | + | Key: "workspace.popups.placement", | |||
| 473 | + | Type: "string", | |||
| 474 | + | Default: defaults.Workspace.Popups.Placement, | |||
| 475 | + | Description: "Placement direction for split popups", | |||
| 476 | + | Values: []string{"right", "left", "top", "bottom"}, | |||
| 477 | + | Section: SectionWorkspace, | |||
| 478 | + | }, | |||
| 479 | + | { | |||
| 480 | + | Key: "workspace.popups.blank_target_behavior", | |||
| 481 | + | Type: "string", | |||
| 482 | + | Default: defaults.Workspace.Popups.BlankTargetBehavior, | |||
| 483 | + | Description: "How to handle target=\"_blank\" links", | |||
| 484 | + | Values: []string{"split", "stacked", "tabbed"}, | |||
| 485 | + | Section: SectionWorkspace, | |||
| 486 | + | }, | |||
| 487 | + | { | |||
| 488 | + | Key: "workspace.popups.open_in_new_pane", | |||
| 489 | + | Type: "bool", | |||
| 490 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OpenInNewPane), | |||
| 491 | + | Description: "Open popups in workspace instead of blocking", | |||
| 492 | + | Section: SectionWorkspace, | |||
| 493 | + | }, | |||
| 494 | + | { | |||
| 495 | + | Key: "workspace.popups.follow_pane_context", | |||
| 496 | + | Type: "bool", | |||
| 497 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.FollowPaneContext), | |||
| 498 | + | Description: "Popup placement follows parent pane context", | |||
| 499 | + | Section: SectionWorkspace, | |||
| 500 | + | }, | |||
| 501 | + | { | |||
| 502 | + | Key: "workspace.popups.enable_smart_detection", | |||
| 503 | + | Type: "bool", | |||
| 504 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.EnableSmartDetection), | |||
| 505 | + | Description: "Use window properties to detect popup intent", | |||
| 506 | + | Section: SectionWorkspace, | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | Key: "workspace.popups.oauth_auto_close", | |||
| 510 | + | Type: "bool", | |||
| 511 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OAuthAutoClose), | |||
| 512 | + | Description: "Auto-close OAuth popups after success", | |||
| 513 | + | Section: SectionWorkspace, | |||
| 514 | + | }, | |||
| 515 | + | // Styling | |||
| 516 | + | { | |||
| 517 | + | Key: "workspace.styling.border_width", | |||
| 518 | + | Type: "int", | |||
| 519 | + | Default: fmt.Sprintf("%d", defaults.Workspace.Styling.BorderWidth), | |||
| 520 | + | Description: "Active pane border width in pixels", | |||
| 521 | + | Range: ">=0", | |||
| 522 | + | Section: SectionWorkspace, | |||
| 523 | + | }, | |||
| 524 | + | { | |||
| 525 | + | Key: "workspace.styling.border_color", | |||
| 526 | + | Type: "string", | |||
| 527 | + | Default: defaults.Workspace.Styling.BorderColor, | |||
| 528 | + | Description: "Active pane border color (CSS or @theme variable)", | |||
| 529 | + | Section: SectionWorkspace, | |||
| 530 | + | }, | |||
| 531 | + | { | |||
| 532 | + | Key: "workspace.styling.mode_border_width", | |||
| 533 | + | Type: "int", | |||
| 534 | + | Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeBorderWidth), | |||
| 535 | + | Description: "Border width for modal mode indicators", | |||
| 536 | + | Range: ">=0", | |||
| 537 | + | Section: SectionWorkspace, | |||
| 538 | + | }, | |||
| 539 | + | { | |||
| 540 | + | Key: "workspace.styling.pane_mode_color", | |||
| 541 | + | Type: "string", | |||
| 542 | + | Default: defaults.Workspace.Styling.PaneModeColor, | |||
| 543 | + | Description: "Color for pane mode indicator", | |||
| 544 | + | Section: SectionWorkspace, | |||
| 545 | + | }, | |||
| 546 | + | { | |||
| 547 | + | Key: "workspace.styling.tab_mode_color", | |||
| 548 | + | Type: "string", | |||
| 549 | + | Default: defaults.Workspace.Styling.TabModeColor, | |||
| 550 | + | Description: "Color for tab mode indicator", | |||
| 551 | + | Section: SectionWorkspace, | |||
| 552 | + | }, | |||
| 553 | + | { | |||
| 554 | + | Key: "workspace.styling.session_mode_color", | |||
| 555 | + | Type: "string", | |||
| 556 | + | Default: defaults.Workspace.Styling.SessionModeColor, | |||
| 557 | + | Description: "Color for session mode indicator", | |||
| 558 | + | Section: SectionWorkspace, | |||
| 559 | + | }, | |||
| 560 | + | { | |||
| 561 | + | Key: "workspace.styling.resize_mode_color", | |||
| 562 | + | Type: "string", | |||
| 563 | + | Default: defaults.Workspace.Styling.ResizeModeColor, | |||
| 564 | + | Description: "Color for resize mode indicator", | |||
| 565 | + | Section: SectionWorkspace, | |||
| 566 | + | }, | |||
| 567 | + | { | |||
| 568 | + | Key: "workspace.styling.mode_indicator_toaster_enabled", | |||
| 569 | + | Type: "bool", | |||
| 570 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled), | |||
| 571 | + | Description: "Show toaster notification for modal modes", | |||
| 572 | + | Section: SectionWorkspace, | |||
| 573 | + | }, | |||
| 574 | + | { | |||
| 575 | + | Key: "workspace.styling.transition_duration", | |||
| 576 | + | Type: "int", | |||
| 577 | + | Default: fmt.Sprintf("%d", defaults.Workspace.Styling.TransitionDuration), | |||
| 578 | + | Description: "Border animation duration in milliseconds", | |||
| 579 | + | Range: ">=0", | |||
| 580 | + | Section: SectionWorkspace, | |||
| 581 | + | }, | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | func (*SchemaProvider) getSessionKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 586 | + | return []entity.ConfigKeyInfo{ | |||
| 587 | + | { | |||
| 588 | + | Key: "session.auto_restore", | |||
| 589 | + | Type: "bool", | |||
| 590 | + | Default: fmt.Sprintf("%t", defaults.Session.AutoRestore), | |||
| 591 | + | Description: "Automatically restore last session on startup", | |||
| 592 | + | Section: SectionSession, | |||
| 593 | + | }, | |||
| 594 | + | { | |||
| 595 | + | Key: "session.snapshot_interval_ms", | |||
| 596 | + | Type: "int", | |||
| 597 | + | Default: fmt.Sprintf("%d", defaults.Session.SnapshotIntervalMs), | |||
| 598 | + | Description: "Minimum interval between session snapshots", | |||
| 599 | + | Range: ">=0", | |||
| 600 | + | Section: SectionSession, | |||
| 601 | + | }, | |||
| 602 | + | { | |||
| 603 | + | Key: "session.max_exited_sessions", | |||
| 604 | + | Type: "int", | |||
| 605 | + | Default: fmt.Sprintf("%d", defaults.Session.MaxExitedSessions), | |||
| 606 | + | Description: "Maximum number of exited sessions to keep", | |||
| 607 | + | Range: ">=0", | |||
| 608 | + | Section: SectionSession, | |||
| 609 | + | }, | |||
| 610 | + | { | |||
| 611 | + | Key: "session.max_exited_session_age_days", | |||
| 612 | + | Type: "int", | |||
| 613 | + | Default: fmt.Sprintf("%d", defaults.Session.MaxExitedSessionAgeDays), | |||
| 614 | + | Description: "Maximum age of exited sessions in days", | |||
| 615 | + | Range: ">=0", | |||
| 616 | + | Section: SectionSession, | |||
| 617 | + | }, | |||
| 618 | + | { | |||
| 619 | + | Key: "session.session_mode.activation_shortcut", | |||
| 620 | + | Type: "string", | |||
| 621 | + | Default: defaults.Session.SessionMode.ActivationShortcut, | |||
| 622 | + | Description: "Shortcut to enter session mode", | |||
| 623 | + | Section: SectionSession, | |||
| 624 | + | }, | |||
| 625 | + | { | |||
| 626 | + | Key: "session.session_mode.timeout_ms", | |||
| 627 | + | Type: "int", | |||
| 628 | + | Default: fmt.Sprintf("%d", defaults.Session.SessionMode.TimeoutMilliseconds), | |||
| 629 | + | Description: "Session mode timeout in milliseconds", | |||
| 630 | + | Range: ">=0", | |||
| 631 | + | Section: SectionSession, | |||
| 632 | + | }, | |||
| 633 | + | { | |||
| 634 | + | Key: "session.session_mode.actions.<action>", | |||
| 635 | + | Type: "[]string", | |||
| 636 | + | Default: "(see defaults)", | |||
| 637 | + | Description: "Key bindings for session mode actions", | |||
| 638 | + | Section: SectionSession, | |||
| 639 | + | }, | |||
| 640 | + | } | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 644 | + | return []entity.ConfigKeyInfo{ | |||
| 645 | + | { | |||
| 646 | + | Key: "omnibox.initial_behavior", | |||
| 647 | + | Type: "string", | |||
| 648 | + | Default: defaults.Omnibox.InitialBehavior, | |||
| 649 | + | Description: "What to show when omnibox opens with empty input", | |||
| 650 | + | Values: []string{"recent", "most_visited", "none"}, | |||
| 651 | + | Section: SectionOmnibox, | |||
| 652 | + | }, | |||
| 653 | + | { | |||
| 654 | + | Key: "omnibox.auto_open_on_new_pane", | |||
| 655 | + | Type: "bool", | |||
| 656 | + | Default: fmt.Sprintf("%t", defaults.Omnibox.AutoOpenOnNewPane), | |||
| 657 | + | Description: "Auto-open omnibox when creating new pane", | |||
| 658 | + | Section: SectionOmnibox, | |||
| 659 | + | }, | |||
| 660 | + | } | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | func (*SchemaProvider) getContentFilteringKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 664 | + | return []entity.ConfigKeyInfo{ | |||
| 665 | + | { | |||
| 666 | + | Key: "content_filtering.enabled", | |||
| 667 | + | Type: "bool", | |||
| 668 | + | Default: fmt.Sprintf("%t", defaults.ContentFiltering.Enabled), | |||
| 669 | + | Description: "Enable ad blocking and content filtering", | |||
| 670 | + | Section: SectionContentFiltering, | |||
| 671 | + | }, | |||
| 672 | + | { | |||
| 673 | + | Key: "content_filtering.auto_update", | |||
| 674 | + | Type: "bool", | |||
| 675 | + | Default: fmt.Sprintf("%t", defaults.ContentFiltering.AutoUpdate), | |||
| 676 | + | Description: "Auto-update filter lists from GitHub", | |||
| 677 | + | Section: SectionContentFiltering, | |||
| 678 | + | }, | |||
| 679 | + | } | |||
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 683 | + | return []entity.ConfigKeyInfo{ | |||
| 684 | + | { | |||
| 685 | + | Key: "rendering.mode", | |||
| 686 | + | Type: "string", | |||
| 687 | + | Default: string(defaults.Rendering.Mode), | |||
| 688 | + | Description: "GPU/CPU rendering selection for WebKit", | |||
| 689 | + | Values: []string{"auto", "gpu", "cpu"}, | |||
| 690 | + | Section: SectionRendering, | |||
| 691 | + | }, | |||
| 692 | + | { | |||
| 693 | + | Key: "rendering.gsk_renderer", | |||
| 694 | + | Type: "string", | |||
| 695 | + | Default: string(defaults.Rendering.GSKRenderer), | |||
| 696 | + | Description: "GTK Scene Kit renderer selection", | |||
| 697 | + | Values: []string{"auto", "opengl", "vulkan", "cairo"}, | |||
| 698 | + | Section: SectionRendering, | |||
| 699 | + | }, | |||
| 700 | + | { | |||
| 701 | + | Key: "rendering.disable_dmabuf_renderer", | |||
| 702 | + | Type: "bool", | |||
| 703 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DisableDMABufRenderer), | |||
| 704 | + | Description: "Disable DMA-BUF accelerated renderer", | |||
| 705 | + | Section: SectionRendering, | |||
| 706 | + | }, | |||
| 707 | + | { | |||
| 708 | + | Key: "rendering.force_compositing_mode", | |||
| 709 | + | Type: "bool", | |||
| 710 | + | Default: fmt.Sprintf("%t", defaults.Rendering.ForceCompositingMode), | |||
| 711 | + | Description: "Force accelerated compositing", | |||
| 712 | + | Section: SectionRendering, | |||
| 713 | + | }, | |||
| 714 | + | { | |||
| 715 | + | Key: "rendering.disable_compositing_mode", | |||
| 716 | + | Type: "bool", | |||
| 717 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DisableCompositingMode), | |||
| 718 | + | Description: "Disable accelerated compositing", | |||
| 719 | + | Section: SectionRendering, | |||
| 720 | + | }, | |||
| 721 | + | { | |||
| 722 | + | Key: "rendering.disable_mipmaps", | |||
| 723 | + | Type: "bool", | |||
| 724 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DisableMipmaps), | |||
| 725 | + | Description: "Disable mipmap generation", | |||
| 726 | + | Section: SectionRendering, | |||
| 727 | + | }, | |||
| 728 | + | { | |||
| 729 | + | Key: "rendering.prefer_gl", | |||
| 730 | + | Type: "bool", | |||
| 731 | + | Default: fmt.Sprintf("%t", defaults.Rendering.PreferGL), | |||
| 732 | + | Description: "Force OpenGL over GLES", | |||
| 733 | + | Section: SectionRendering, | |||
| 734 | + | }, | |||
| 735 | + | { | |||
| 736 | + | Key: "rendering.draw_compositing_indicators", | |||
| 737 | + | Type: "bool", | |||
| 738 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DrawCompositingIndicators), | |||
| 739 | + | Description: "Show compositing layer borders", | |||
| 740 | + | Section: SectionRendering, | |||
| 741 | + | }, | |||
| 742 | + | { | |||
| 743 | + | Key: "rendering.show_fps", | |||
| 744 | + | Type: "bool", | |||
| 745 | + | Default: fmt.Sprintf("%t", defaults.Rendering.ShowFPS), | |||
| 746 | + | Description: "Display WebKit FPS counter", | |||
| 747 | + | Section: SectionRendering, | |||
| 748 | + | }, | |||
| 749 | + | { | |||
| 750 | + | Key: "rendering.sample_memory", | |||
| 751 | + | Type: "bool", | |||
| 752 | + | Default: fmt.Sprintf("%t", defaults.Rendering.SampleMemory), | |||
| 753 | + | Description: "Enable memory sampling", | |||
| 754 | + | Section: SectionRendering, | |||
| 755 | + | }, | |||
| 756 | + | { | |||
| 757 | + | Key: "rendering.debug_frames", | |||
| 758 | + | Type: "bool", | |||
| 759 | + | Default: fmt.Sprintf("%t", defaults.Rendering.DebugFrames), | |||
| 760 | + | Description: "Enable frame timing debug output", | |||
| 761 | + | Section: SectionRendering, | |||
| 762 | + | }, | |||
| 763 | + | } | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 767 | + | return []entity.ConfigKeyInfo{ | |||
| 768 | + | { | |||
| 769 | + | Key: "media.hardware_decoding", | |||
| 770 | + | Type: "string", | |||
| 771 | + | Default: string(defaults.Media.HardwareDecodingMode), | |||
| 772 | + | Description: "Hardware vs software video decoding", | |||
| 773 | + | Values: []string{"auto", "force", "disable"}, | |||
| 774 | + | Section: SectionMedia, | |||
| 775 | + | }, | |||
| 776 | + | { | |||
| 777 | + | Key: "media.gl_rendering_mode", | |||
| 778 | + | Type: "string", | |||
| 779 | + | Default: string(defaults.Media.GLRenderingMode), | |||
| 780 | + | Description: "OpenGL API selection for video rendering", | |||
| 781 | + | Values: []string{"auto", "gles2", "gl3", "none"}, | |||
| 782 | + | Section: SectionMedia, | |||
| 783 | + | }, | |||
| 784 | + | { | |||
| 785 | + | Key: "media.prefer_av1", | |||
| 786 | + | Type: "bool", | |||
| 787 | + | Default: fmt.Sprintf("%t", defaults.Media.PreferAV1), | |||
| 788 | + | Description: "Prioritize AV1 codec when available", | |||
| 789 | + | Section: SectionMedia, | |||
| 790 | + | }, | |||
| 791 | + | { | |||
| 792 | + | Key: "media.show_diagnostics", | |||
| 793 | + | Type: "bool", | |||
| 794 | + | Default: fmt.Sprintf("%t", defaults.Media.ShowDiagnosticsOnStartup), | |||
| 795 | + | Description: "Show media capability warnings at startup", | |||
| 796 | + | Section: SectionMedia, | |||
| 797 | + | }, | |||
| 798 | + | { | |||
| 799 | + | Key: "media.force_vsync", | |||
| 800 | + | Type: "bool", | |||
| 801 | + | Default: fmt.Sprintf("%t", defaults.Media.ForceVSync), | |||
| 802 | + | Description: "Force vertical sync for video playback", | |||
| 803 | + | Section: SectionMedia, | |||
| 804 | + | }, | |||
| 805 | + | { | |||
| 806 | + | Key: "media.gstreamer_debug_level", | |||
| 807 | + | Type: "int", | |||
| 808 | + | Default: fmt.Sprintf("%d", defaults.Media.GStreamerDebugLevel), | |||
| 809 | + | Description: "GStreamer debug verbosity (0=off, 1-5)", | |||
| 810 | + | Range: "0-5", | |||
| 811 | + | Section: SectionMedia, | |||
| 812 | + | }, | |||
| 813 | + | } | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | func (*SchemaProvider) getUpdateKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 817 | + | return []entity.ConfigKeyInfo{ | |||
| 818 | + | { | |||
| 819 | + | Key: "update.enable_on_startup", | |||
| 820 | + | Type: "bool", | |||
| 821 | + | Default: fmt.Sprintf("%t", defaults.Update.EnableOnStartup), | |||
| 822 | + | Description: "Check for updates on browser startup", | |||
| 823 | + | Section: SectionUpdate, | |||
| 824 | + | }, | |||
| 825 | + | { | |||
| 826 | + | Key: "update.auto_download", | |||
| 827 | + | Type: "bool", | |||
| 828 | + | Default: fmt.Sprintf("%t", defaults.Update.AutoDownload), | |||
| 829 | + | Description: "Auto-download updates in background", | |||
| 830 | + | Section: SectionUpdate, | |||
| 831 | + | }, | |||
| 832 | + | { | |||
| 833 | + | Key: "update.notify_on_new_settings", | |||
| 834 | + | Type: "bool", | |||
| 835 | + | Default: fmt.Sprintf("%t", defaults.Update.NotifyOnNewSettings), | |||
| 836 | + | Description: "Show toast when new config settings available", | |||
| 837 | + | Section: SectionUpdate, | |||
| 838 | + | }, | |||
| 839 | + | } | |||
| 840 | + | } | |||
| 841 | + | ||||
| 842 | + | func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 843 | + | return []entity.ConfigKeyInfo{ | |||
| 844 | + | { | |||
| 845 | + | Key: "debug.enable_devtools", | |||
| 846 | + | Type: "bool", | |||
| 847 | + | Default: fmt.Sprintf("%t", defaults.Debug.EnableDevTools), | |||
| 848 | + | Description: "Enable browser developer tools (F12)", | |||
| 849 | + | Section: SectionDebug, | |||
| 850 | + | }, | |||
| 851 | + | } | |||
| 852 | + | } | |||
| 853 | + | ||||
| 854 | + | func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 855 | + | return []entity.ConfigKeyInfo{ | |||
| 856 | + | { | |||
| 857 | + | Key: "performance.zoom_cache_size", | |||
| 858 | + | Type: "int", | |||
| 859 | + | Default: fmt.Sprintf("%d", defaults.Performance.ZoomCacheSize), | |||
| 860 | + | Description: "Number of domain zoom levels to cache", | |||
| 861 | + | Range: ">=0", | |||
| 862 | + | Section: SectionPerformance, | |||
| 863 | + | }, | |||
| 864 | + | { | |||
| 865 | + | Key: "performance.webview_pool_prewarm_count", | |||
| 866 | + | Type: "int", | |||
| 867 | + | Default: fmt.Sprintf("%d", defaults.Performance.WebViewPoolPrewarmCount), | |||
| 868 | + | Description: "WebViews to pre-create at startup", | |||
| 869 | + | Range: ">=0", | |||
| 870 | + | Section: SectionPerformance, | |||
| 871 | + | }, | |||
| 872 | + | } | |||
| 873 | + | } | |||
| 874 | + | ||||
| 875 | + | func (*SchemaProvider) getRuntimeKeys(_ *Config) []entity.ConfigKeyInfo { | |||
| 876 | + | return []entity.ConfigKeyInfo{ | |||
| 877 | + | { | |||
| 878 | + | Key: "runtime.prefix", | |||
| 879 | + | Type: "string", | |||
| 880 | + | Default: "(empty)", | |||
| 881 | + | Description: "Custom runtime prefix for WebKitGTK/GTK", | |||
| 882 | + | Section: SectionRuntime, | |||
| 883 | + | }, | |||
| 884 | + | } | |||
| 885 | + | } | |||
| 886 | + | ||||
| 887 | + | func (*SchemaProvider) getDatabaseKeys() []entity.ConfigKeyInfo { | |||
| 888 | + | // Get actual default path for display | |||
| 889 | + | dbPath := "$XDG_DATA_HOME/" + appName + "/" + databaseName | |||
| 890 | + | if path, err := GetDatabaseFile(); err == nil { | |||
| 891 | + | dbPath = path | |||
| 892 | + | } | |||
| 893 | + | ||||
| 894 | + | return []entity.ConfigKeyInfo{ | |||
| 895 | + | { | |||
| 896 | + | Key: "database.path", | |||
| 897 | + | Type: "string", | |||
| 898 | + | Default: dbPath, | |||
| 899 | + | Description: "Path to SQLite database file", | |||
| 900 | + | Section: SectionDatabase, | |||
| 901 | + | }, | |||
| 902 | + | } | |||
| 903 | + | } | |||
M internal/ui/component/omnibox.go +67 -19
| @@ -98,7 +98,7 @@ type Omnibox struct { | |||
| 98 | 98 | // Callbacks | |
| 99 | 99 | onNavigate func(url string) | |
| 100 | 100 | onClose func() | |
| 101 | - | onToast func(message string) | |
| 101 | + | onToast func(ctx context.Context, message string, level ToastLevel) | |
| 102 | 102 | ||
| 103 | 103 | // Debouncing | |
| 104 | 104 | debounceTimer *time.Timer | |
| @@ -129,9 +129,9 @@ type OmniboxConfig struct { | |||
| 129 | 129 | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 130 | 130 | DefaultSearch string | |
| 131 | 131 | InitialBehavior string | |
| 132 | - | UIScale float64 // UI scale for favicon sizing | |
| 133 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 134 | - | OnToast func(message string) // Callback to show toast notification | |
| 132 | + | UIScale float64 // UI scale for favicon sizing | |
| 133 | + | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 134 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -1291,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1291 | 1291 | } | |
| 1292 | 1292 | ||
| 1293 | 1293 | if mode == ViewModeHistory { | |
| 1294 | - | // Add to favorites | |
| 1294 | + | // Toggle favorite in history mode | |
| 1295 | 1295 | if idx < 0 || idx >= len(suggestions) { | |
| 1296 | - | log.Debug().Int("index", idx).Msg("add favorite: invalid selection") | |
| 1296 | + | log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection") | |
| 1297 | 1297 | return | |
| 1298 | 1298 | } | |
| 1299 | 1299 | ||
| 1300 | 1300 | s := suggestions[idx] | |
| 1301 | 1301 | if s.URL == "" { | |
| 1302 | - | log.Debug().Msg("add favorite: empty URL") | |
| 1302 | + | log.Debug().Msg("toggle favorite: empty URL") | |
| 1303 | 1303 | return | |
| 1304 | 1304 | } | |
| 1305 | 1305 | ||
| @@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1307 | 1307 | ctx := o.ctx | |
| 1308 | 1308 | goLog := logging.FromContext(ctx) | |
| 1309 | 1309 | ||
| 1310 | - | goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites") | |
| 1311 | - | ||
| 1312 | - | addInput := usecase.AddFavoriteInput{ | |
| 1313 | - | URL: s.URL, | |
| 1314 | - | Title: s.Title, | |
| 1315 | - | } | |
| 1316 | - | ||
| 1317 | - | fav, err := o.favoritesUC.Add(ctx, addInput) | |
| 1310 | + | result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title) | |
| 1318 | 1311 | if err != nil { | |
| 1319 | - | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite") | |
| 1312 | + | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite") | |
| 1313 | + | if o.onToast != nil { | |
| 1314 | + | msg := "Failed to add favorite" | |
| 1315 | + | if s.IsFavorite { | |
| 1316 | + | msg = "Failed to remove favorite" | |
| 1317 | + | } | |
| 1318 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1319 | + | o.onToast(ctx, msg, ToastError) | |
| 1320 | + | return false | |
| 1321 | + | }) | |
| 1322 | + | glib.IdleAdd(&cb, 0) | |
| 1323 | + | } | |
| 1320 | 1324 | return | |
| 1321 | 1325 | } | |
| 1322 | 1326 | ||
| 1323 | - | goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox") | |
| 1327 | + | // Update suggestion state | |
| 1328 | + | o.mu.Lock() | |
| 1329 | + | if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL { | |
| 1330 | + | o.suggestions[idx].IsFavorite = result.Added | |
| 1331 | + | } | |
| 1332 | + | o.mu.Unlock() | |
| 1333 | + | ||
| 1334 | + | // Update row CSS and show toast on GTK main thread | |
| 1335 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1336 | + | o.updateRowFavoriteIndicator(idx, result.Added) | |
| 1337 | + | if o.onToast != nil { | |
| 1338 | + | o.onToast(ctx, result.Message, ToastSuccess) | |
| 1339 | + | } | |
| 1340 | + | return false | |
| 1341 | + | }) | |
| 1342 | + | glib.IdleAdd(&cb, 0) | |
| 1324 | 1343 | }() | |
| 1325 | 1344 | } else { | |
| 1326 | 1345 | // Remove from favorites | |
| @@ -1344,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1344 | 1363 | err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID)) | |
| 1345 | 1364 | if err != nil { | |
| 1346 | 1365 | log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite") | |
| 1366 | + | // Show error toast | |
| 1367 | + | if o.onToast != nil { | |
| 1368 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1369 | + | o.onToast(ctx, "Failed to remove favorite", ToastError) | |
| 1370 | + | return false | |
| 1371 | + | }) | |
| 1372 | + | glib.IdleAdd(&cb, 0) | |
| 1373 | + | } | |
| 1347 | 1374 | return | |
| 1348 | 1375 | } | |
| 1349 | 1376 | ||
| 1350 | 1377 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | |
| 1351 | 1378 | ||
| 1352 | - | // Refresh favorites list | |
| 1379 | + | // Refresh favorites list and show toast | |
| 1353 | 1380 | o.loadFavorites(o.entry.GetText()) | |
| 1381 | + | if o.onToast != nil { | |
| 1382 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1383 | + | o.onToast(ctx, "Favorite removed", ToastSuccess) | |
| 1384 | + | return false | |
| 1385 | + | }) | |
| 1386 | + | glib.IdleAdd(&cb, 0) | |
| 1387 | + | } | |
| 1354 | 1388 | }() | |
| 1355 | 1389 | } | |
| 1356 | 1390 | } | |
| 1357 | 1391 | ||
| 1392 | + | // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class. | |
| 1393 | + | // Must be called from GTK main thread (via glib.IdleAdd). | |
| 1394 | + | func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) { | |
| 1395 | + | row := o.listBox.GetRowAtIndex(index) | |
| 1396 | + | if row == nil { | |
| 1397 | + | return | |
| 1398 | + | } | |
| 1399 | + | if isFavorite { | |
| 1400 | + | row.AddCssClass("omnibox-row-favorite") | |
| 1401 | + | } else { | |
| 1402 | + | row.RemoveCssClass("omnibox-row-favorite") | |
| 1403 | + | } | |
| 1404 | + | } | |
| 1405 | + | ||
| 1358 | 1406 | // yankSelectedURL copies the URL of the selected item to clipboard. | |
| 1359 | 1407 | func (o *Omnibox) yankSelectedURL() { | |
| 1360 | 1408 | log := logging.FromContext(o.ctx) | |
| @@ -1400,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() { | |||
| 1400 | 1448 | // Show toast notification on success (must run on GTK main thread) | |
| 1401 | 1449 | if o.onToast != nil { | |
| 1402 | 1450 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1403 | - | o.onToast("URL copied") | |
| 1451 | + | o.onToast(ctx, "URL copied", ToastSuccess) | |
| 1404 | 1452 | return false // Don't repeat | |
| 1405 | 1453 | }) | |
| 1406 | 1454 | glib.IdleAdd(&cb, 0) | |
| @@ -98,7 +98,7 @@ type Omnibox struct { | |||||
| 98 | // Callbacks | 98 | // Callbacks | ||
| 99 | onNavigate func(url string) | 99 | onNavigate func(url string) | ||
| 100 | onClose func() | 100 | onClose func() | ||
| 101 | - | onToast func(message string) | 101 | + | onToast func(ctx context.Context, message string, level ToastLevel) |
| 102 | 102 | ||||
| 103 | // Debouncing | 103 | // Debouncing | ||
| 104 | debounceTimer *time.Timer | 104 | debounceTimer *time.Timer | ||
| @@ -129,9 +129,9 @@ type OmniboxConfig struct { | |||||
| 129 | ShortcutsUC *usecase.SearchShortcutsUseCase | 129 | ShortcutsUC *usecase.SearchShortcutsUseCase | ||
| 130 | DefaultSearch string | 130 | DefaultSearch string | ||
| 131 | InitialBehavior string | 131 | InitialBehavior string | ||
| 132 | - | UIScale float64 // UI scale for favicon sizing | 132 | + | UIScale float64 // UI scale for favicon sizing |
| 133 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | 133 | + | OnNavigate func(url string) // Callback when user navigates via omnibox |
| 134 | - | OnToast func(message string) // Callback to show toast notification | 134 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification |
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| @@ -1291,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1291 | } | 1291 | } | ||
| 1292 | 1292 | ||||
| 1293 | if mode == ViewModeHistory { | 1293 | if mode == ViewModeHistory { | ||
| 1294 | - | // Add to favorites | 1294 | + | // Toggle favorite in history mode |
| 1295 | if idx < 0 || idx >= len(suggestions) { | 1295 | if idx < 0 || idx >= len(suggestions) { | ||
| 1296 | - | log.Debug().Int("index", idx).Msg("add favorite: invalid selection") | 1296 | + | log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection") |
| 1297 | return | 1297 | return | ||
| 1298 | } | 1298 | } | ||
| 1299 | 1299 | ||||
| 1300 | s := suggestions[idx] | 1300 | s := suggestions[idx] | ||
| 1301 | if s.URL == "" { | 1301 | if s.URL == "" { | ||
| 1302 | - | log.Debug().Msg("add favorite: empty URL") | 1302 | + | log.Debug().Msg("toggle favorite: empty URL") |
| 1303 | return | 1303 | return | ||
| 1304 | } | 1304 | } | ||
| 1305 | 1305 | ||||
| @@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1307 | ctx := o.ctx | 1307 | ctx := o.ctx | ||
| 1308 | goLog := logging.FromContext(ctx) | 1308 | goLog := logging.FromContext(ctx) | ||
| 1309 | 1309 | ||||
| 1310 | - | goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites") | 1310 | + | result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title) |
| 1311 | - | ||||
| 1312 | - | addInput := usecase.AddFavoriteInput{ | |||
| 1313 | - | URL: s.URL, | |||
| 1314 | - | Title: s.Title, | |||
| 1315 | - | } | |||
| 1316 | - | ||||
| 1317 | - | fav, err := o.favoritesUC.Add(ctx, addInput) | |||
| 1318 | if err != nil { | 1311 | if err != nil { | ||
| 1319 | - | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite") | 1312 | + | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite") |
| 1313 | + | if o.onToast != nil { | |||
| 1314 | + | msg := "Failed to add favorite" | |||
| 1315 | + | if s.IsFavorite { | |||
| 1316 | + | msg = "Failed to remove favorite" | |||
| 1317 | + | } | |||
| 1318 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1319 | + | o.onToast(ctx, msg, ToastError) | |||
| 1320 | + | return false | |||
| 1321 | + | }) | |||
| 1322 | + | glib.IdleAdd(&cb, 0) | |||
| 1323 | + | } | |||
| 1320 | return | 1324 | return | ||
| 1321 | } | 1325 | } | ||
| 1322 | 1326 | ||||
| 1323 | - | goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox") | 1327 | + | // Update suggestion state |
| 1328 | + | o.mu.Lock() | |||
| 1329 | + | if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL { | |||
| 1330 | + | o.suggestions[idx].IsFavorite = result.Added | |||
| 1331 | + | } | |||
| 1332 | + | o.mu.Unlock() | |||
| 1333 | + | ||||
| 1334 | + | // Update row CSS and show toast on GTK main thread | |||
| 1335 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1336 | + | o.updateRowFavoriteIndicator(idx, result.Added) | |||
| 1337 | + | if o.onToast != nil { | |||
| 1338 | + | o.onToast(ctx, result.Message, ToastSuccess) | |||
| 1339 | + | } | |||
| 1340 | + | return false | |||
| 1341 | + | }) | |||
| 1342 | + | glib.IdleAdd(&cb, 0) | |||
| 1324 | }() | 1343 | }() | ||
| 1325 | } else { | 1344 | } else { | ||
| 1326 | // Remove from favorites | 1345 | // Remove from favorites | ||
| @@ -1344,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1344 | err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID)) | 1363 | err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID)) | ||
| 1345 | if err != nil { | 1364 | if err != nil { | ||
| 1346 | log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite") | 1365 | log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite") | ||
| 1366 | + | // Show error toast | |||
| 1367 | + | if o.onToast != nil { | |||
| 1368 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1369 | + | o.onToast(ctx, "Failed to remove favorite", ToastError) | |||
| 1370 | + | return false | |||
| 1371 | + | }) | |||
| 1372 | + | glib.IdleAdd(&cb, 0) | |||
| 1373 | + | } | |||
| 1347 | return | 1374 | return | ||
| 1348 | } | 1375 | } | ||
| 1349 | 1376 | ||||
| 1350 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | 1377 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | ||
| 1351 | 1378 | ||||
| 1352 | - | // Refresh favorites list | 1379 | + | // Refresh favorites list and show toast |
| 1353 | o.loadFavorites(o.entry.GetText()) | 1380 | o.loadFavorites(o.entry.GetText()) | ||
| 1381 | + | if o.onToast != nil { | |||
| 1382 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1383 | + | o.onToast(ctx, "Favorite removed", ToastSuccess) | |||
| 1384 | + | return false | |||
| 1385 | + | }) | |||
| 1386 | + | glib.IdleAdd(&cb, 0) | |||
| 1387 | + | } | |||
| 1354 | }() | 1388 | }() | ||
| 1355 | } | 1389 | } | ||
| 1356 | } | 1390 | } | ||
| 1357 | 1391 | ||||
| 1392 | + | // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class. | |||
| 1393 | + | // Must be called from GTK main thread (via glib.IdleAdd). | |||
| 1394 | + | func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) { | |||
| 1395 | + | row := o.listBox.GetRowAtIndex(index) | |||
| 1396 | + | if row == nil { | |||
| 1397 | + | return | |||
| 1398 | + | } | |||
| 1399 | + | if isFavorite { | |||
| 1400 | + | row.AddCssClass("omnibox-row-favorite") | |||
| 1401 | + | } else { | |||
| 1402 | + | row.RemoveCssClass("omnibox-row-favorite") | |||
| 1403 | + | } | |||
| 1404 | + | } | |||
| 1405 | + | ||||
| 1358 | // yankSelectedURL copies the URL of the selected item to clipboard. | 1406 | // yankSelectedURL copies the URL of the selected item to clipboard. | ||
| 1359 | func (o *Omnibox) yankSelectedURL() { | 1407 | func (o *Omnibox) yankSelectedURL() { | ||
| 1360 | log := logging.FromContext(o.ctx) | 1408 | log := logging.FromContext(o.ctx) | ||
| @@ -1400,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() { | |||||
| 1400 | // Show toast notification on success (must run on GTK main thread) | 1448 | // Show toast notification on success (must run on GTK main thread) | ||
| 1401 | if o.onToast != nil { | 1449 | if o.onToast != nil { | ||
| 1402 | cb := glib.SourceFunc(func(_ uintptr) bool { | 1450 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 1403 | - | o.onToast("URL copied") | 1451 | + | o.onToast(ctx, "URL copied", ToastSuccess) |
| 1404 | return false // Don't repeat | 1452 | return false // Don't repeat | ||
| 1405 | }) | 1453 | }) | ||
| 1406 | glib.IdleAdd(&cb, 0) | 1454 | glib.IdleAdd(&cb, 0) | ||
M internal/ui/component/workspace_view.go +2 -2
| @@ -556,8 +556,8 @@ func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) { | |||
| 556 | 556 | ||
| 557 | 557 | // Create omnibox with pane-specific toast callback | |
| 558 | 558 | cfg := wv.omniboxCfg | |
| 559 | - | cfg.OnToast = func(message string) { | |
| 560 | - | pv.ShowToast(ctx, message, ToastSuccess) | |
| 559 | + | cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) { | |
| 560 | + | pv.ShowToast(toastCtx, message, level) | |
| 561 | 561 | } | |
| 562 | 562 | omnibox := NewOmnibox(ctx, cfg) | |
| 563 | 563 | if omnibox == nil { | |
| @@ -556,8 +556,8 @@ func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) { | |||||
| 556 | 556 | ||||
| 557 | // Create omnibox with pane-specific toast callback | 557 | // Create omnibox with pane-specific toast callback | ||
| 558 | cfg := wv.omniboxCfg | 558 | cfg := wv.omniboxCfg | ||
| 559 | - | cfg.OnToast = func(message string) { | 559 | + | cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) { |
| 560 | - | pv.ShowToast(ctx, message, ToastSuccess) | 560 | + | pv.ShowToast(toastCtx, message, level) |
| 561 | } | 561 | } | ||
| 562 | omnibox := NewOmnibox(ctx, cfg) | 562 | omnibox := NewOmnibox(ctx, cfg) | ||
| 563 | if omnibox == nil { | 563 | if omnibox == nil { | ||