detached 30ff31f

Merge pull request #57 from bnema/misc-fixes

Misc fixes: omnibox toggle, config migrate, config open, config schema
commit
30ff31f354dd3e65a2e433ee45b6aae19ecc63a8
author
bnema · · 2025-12-28 18:53 UTC
committer
GitHub · · 2025-12-28 18:53 UTC
merge of
2391e76 + e4dbd73 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +2181 -65
M .mockery.yaml +1 -0
@@ -9,6 +9,7 @@ packages:
99 pkgname: mocks
1010 interfaces:
1111 ConfigMigrator: {}
12+ ConfigSchemaProvider: {}
1213 DatabaseProvider: {}
1314 DiffFormatter: {}
1415 SessionLogger: {}
@@ -9,6 +9,7 @@ packages:
9 pkgname: mocks9 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.
1919 - `--force` / `-f` flag to skip version check and reinstall the current version.
2020 - Reuses existing auto-update infrastructure (check, download, stage, apply on exit).
2121 - 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.
2224 - **Move pane to tab**: New pane-mode actions to move the active pane to another tab.
2325 - `Ctrl+P → m`: Opens a tab picker modal.
2426 - `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.
4042 - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
4143
4244 ### 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.
4346 - **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.
4448 - **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.
4549 - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode.
4650 - **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 ### Fixed44 ### 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 {
6363 // This provides a detailed diff-like view of what migration would do.
6464 DetectChanges() ([]KeyChange, error)
6565
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.
6868 Migrate() ([]string, error)
6969
7070 // GetKeyInfo returns detailed information about a config key.
7171 GetKeyInfo(key string) KeyInfo
72+
73+ // GetConfigFile returns the path to the user's config file.
74+ GetConfigFile() (string, error)
7275 }
7376
7477 // 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) KeyInfo71 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
151151 return _c
152152 }
153153
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+
154207 // GetKeyInfo provides a mock function for the type MockConfigMigrator
155208 func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {
156209 ret := _mock.Called(key)
@@ -335,6 +388,79 @@ func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(chan
335388 return _c
336389 }
337390
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+
338464 // 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.
339465 // The first argument is typically a *testing.T value.
340466 func NewMockDatabaseProvider(t interface {
@@ -151,6 +151,59 @@ func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port
151 return _c151 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 MockConfigMigrator207 // 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 _c388 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
196196 return fav != nil, nil
197197 }
198198
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+
199251 // GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
200252 func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
201253 log := logging.FromContext(ctx)
@@ -196,6 +196,58 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196 return fav != nil, nil196 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.
7575 assert.Nil(t, urls)
7676 assert.Contains(t, err.Error(), "failed to get favorites")
7777 }
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
126126 }, nil
127127 }
128128
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.
130130 func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) {
131131 log := logging.FromContext(ctx)
132132
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()
135135 if err != nil {
136136 return nil, err
137137 }
138138
139- if checkResult == nil || len(checkResult.MissingKeys) == 0 {
139+ if len(changes) == 0 {
140140 log.Debug().Msg("no migration needed")
141141 return &MigrateConfigOutput{
142142 AddedKeys: nil,
@@ -144,20 +144,23 @@ func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInpu
144144 }, nil
145145 }
146146
147+ // Get config file path for output
148+ configFile, _ := uc.migrator.GetConfigFile()
149+
147150 // Perform migration
148- addedKeys, err := uc.migrator.Migrate()
151+ appliedKeys, err := uc.migrator.Migrate()
149152 if err != nil {
150153 log.Error().Err(err).Msg("config migration failed")
151154 return nil, err
152155 }
153156
154157 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).
157160 Msg("config migration completed")
158161
159162 return &MigrateConfigOutput{
160- AddedKeys: addedKeys,
161- ConfigFile: checkResult.ConfigFile,
163+ AddedKeys: appliedKeys,
164+ ConfigFile: configFile,
162165 }, nil
163166 }
@@ -126,17 +126,17 @@ func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChang
126 }, nil126 }, 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 needed133+ // 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, err136 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 }, nil144 }, nil
145 }145 }
146 146
147+ // Get config file path for output
148+ configFile, _ := uc.migrator.GetConfigFile()
149+
147 // Perform migration150 // 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, err154 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 }, nil165 }, nil
163 }166 }
M internal/application/usecase/migrate_config_test.go +13 -12
@@ -86,7 +86,7 @@ func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
8686 func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
8787 mockMigrator := mocks.NewMockConfigMigrator(t)
8888 mockFormatter := mocks.NewMockDiffFormatter(t)
89- mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
89+ mockMigrator.EXPECT().DetectChanges().Return(nil, nil)
9090
9191 uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
9292 ctx := context.Background()
@@ -101,11 +101,12 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
101101 mockMigrator := mocks.NewMockConfigMigrator(t)
102102 mockFormatter := mocks.NewMockDiffFormatter(t)
103103
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"},
107107 }
108- mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
108+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
109+ mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil)
109110 mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil)
110111
111112 uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
@@ -122,11 +123,11 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
122123 mockMigrator := mocks.NewMockConfigMigrator(t)
123124 mockFormatter := mocks.NewMockDiffFormatter(t)
124125
125- migrationResult := &port.MigrationResult{
126- MissingKeys: []string{"key1"},
127- ConfigFile: "/path/to/config.toml",
126+ changes := []port.KeyChange{
127+ {Type: port.KeyChangeAdded, NewKey: "key1"},
128128 }
129- mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
129+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
130+ mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil)
130131
131132 expectedErr := errors.New("migrate failed")
132133 mockMigrator.EXPECT().Migrate().Return(nil, expectedErr)
@@ -141,12 +142,12 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
141142 assert.Equal(t, expectedErr, err)
142143 }
143144
144-func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {
145+func TestMigrateConfigUseCase_Execute_DetectChangesError(t *testing.T) {
145146 mockMigrator := mocks.NewMockConfigMigrator(t)
146147 mockFormatter := mocks.NewMockDiffFormatter(t)
147148
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)
150151
151152 uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
152153 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 (
44 "context"
55 "fmt"
66 "os"
7+ "os/exec"
78
89 "github.com/charmbracelet/bubbles/spinner"
910 tea "github.com/charmbracelet/bubbletea"
@@ -24,6 +25,18 @@ var configCmd = &cobra.Command{
2425 Long: `View configuration status and migrate to add new default settings.`,
2526 }
2627
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+
2740 var configStatusCmd = &cobra.Command{
2841 Use: "status",
2942 Short: "Show config file status and migration availability",
@@ -40,11 +53,105 @@ Existing settings are never modified - only missing keys are added with default
4053 RunE: runConfigMigrate,
4154 }
4255
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+
4368 func init() {
4469 rootCmd.AddCommand(configCmd)
70+ configCmd.AddCommand(configOpenCmd)
4571 configCmd.AddCommand(configStatusCmd)
4672 configCmd.AddCommand(configMigrateCmd)
73+ configCmd.AddCommand(configSchemaCmd)
4774 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 ""
48155 }
49156
50157 // 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 @@
11 package config
22
33 import (
4+ "encoding/json"
45 "fmt"
56 "os"
7+ "path/filepath"
68 "reflect"
79 "sort"
810 "strings"
911
1012 "github.com/pelletier/go-toml/v2"
1113 "github.com/spf13/viper"
14+ "gopkg.in/yaml.v3"
1215
1316 "github.com/bnema/dumber/internal/application/port"
1417 )
@@ -252,6 +255,13 @@ const (
252255 typeNameString = "string"
253256 )
254257
258+// Config format constants.
259+const (
260+ configFormatTOML = "toml"
261+ configFormatYAML = "yaml"
262+ configFormatJSON = "json"
263+)
264+
255265 // typesAreCompatible checks if the default types for two keys are compatible.
256266 // This prevents matching int keys with string keys during rename detection.
257267 func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
@@ -339,7 +349,7 @@ func keysAreSimilar(oldKey, newKey string) bool {
339349 return matches >= minTokens-1 && matches > 0
340350 }
341351
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.
343353 func (m *Migrator) Migrate() ([]string, error) {
344354 changes, err := m.DetectChanges()
345355 if err != nil {
@@ -355,39 +365,57 @@ func (m *Migrator) Migrate() ([]string, error) {
355365 return nil, fmt.Errorf("failed to get config file path: %w", err)
356366 }
357367
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 {
369371 return nil, fmt.Errorf("failed to read config file: %w", err)
370372 }
371373
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+
373378 var appliedKeys []string
374379 for _, change := range changes {
375380 switch change.Type {
376381 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))
382385 case port.KeyChangeAdded:
383- // New key is already set via defaults, just track it
384386 appliedKeys = append(appliedKeys, change.NewKey)
385387 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)
388397 }
389398 }
390399
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+
391419 // Write the merged config back
392420 if err := userViper.WriteConfig(); err != nil {
393421 return nil, fmt.Errorf("failed to write config file: %w", err)
@@ -396,6 +424,149 @@ func (m *Migrator) Migrate() ([]string, error) {
396424 return appliedKeys, nil
397425 }
398426
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+
399570 // GetKeyInfo returns detailed information about a config key.
400571 func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
401572 value := m.defaultViper.Get(key)
@@ -414,6 +585,11 @@ func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
414585 }
415586 }
416587
588+// GetConfigFile returns the path to the user's config file.
589+func (*Migrator) GetConfigFile() (string, error) {
590+ return GetConfigFile()
591+}
592+
417593 // getAllDefaultKeys returns all keys from the default configuration.
418594 func (m *Migrator) getAllDefaultKeys() []string {
419595 keys := m.defaultViper.AllKeys()
@@ -1,14 +1,17 @@
1 package config1 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 > 0349 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 config368+ // 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 changes374+ // 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 []string378 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 key382+ 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 used388+ 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 back419 // 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, nil424 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) {
628628 // which is difficult without refactoring. Skip for now.
629629 t.Skip("requires config path mocking")
630630 }
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 {
9898 // Callbacks
9999 onNavigate func(url string)
100100 onClose func()
101- onToast func(message string)
101+ onToast func(ctx context.Context, message string, level ToastLevel)
102102
103103 // Debouncing
104104 debounceTimer *time.Timer
@@ -129,9 +129,9 @@ type OmniboxConfig struct {
129129 ShortcutsUC *usecase.SearchShortcutsUseCase
130130 DefaultSearch string
131131 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
135135 }
136136
137137 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -1291,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() {
12911291 }
12921292
12931293 if mode == ViewModeHistory {
1294- // Add to favorites
1294+ // Toggle favorite in history mode
12951295 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")
12971297 return
12981298 }
12991299
13001300 s := suggestions[idx]
13011301 if s.URL == "" {
1302- log.Debug().Msg("add favorite: empty URL")
1302+ log.Debug().Msg("toggle favorite: empty URL")
13031303 return
13041304 }
13051305
@@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() {
13071307 ctx := o.ctx
13081308 goLog := logging.FromContext(ctx)
13091309
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)
13181311 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+ }
13201324 return
13211325 }
13221326
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)
13241343 }()
13251344 } else {
13261345 // Remove from favorites
@@ -1344,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() {
13441363 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
13451364 if err != nil {
13461365 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+ }
13471374 return
13481375 }
13491376
13501377 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
13511378
1352- // Refresh favorites list
1379+ // Refresh favorites list and show toast
13531380 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+ }
13541388 }()
13551389 }
13561390 }
13571391
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+
13581406 // yankSelectedURL copies the URL of the selected item to clipboard.
13591407 func (o *Omnibox) yankSelectedURL() {
13601408 log := logging.FromContext(o.ctx)
@@ -1400,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() {
14001448 // Show toast notification on success (must run on GTK main thread)
14011449 if o.onToast != nil {
14021450 cb := glib.SourceFunc(func(_ uintptr) bool {
1403- o.onToast("URL copied")
1451+ o.onToast(ctx, "URL copied", ToastSuccess)
14041452 return false // Don't repeat
14051453 })
14061454 glib.IdleAdd(&cb, 0)
@@ -98,7 +98,7 @@ type Omnibox struct {
98 // Callbacks98 // 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 // Debouncing103 // Debouncing
104 debounceTimer *time.Timer104 debounceTimer *time.Timer
@@ -129,9 +129,9 @@ type OmniboxConfig struct {
129 ShortcutsUC *usecase.SearchShortcutsUseCase129 ShortcutsUC *usecase.SearchShortcutsUseCase
130 DefaultSearch string130 DefaultSearch string
131 InitialBehavior string131 InitialBehavior string
132- UIScale float64 // UI scale for favicon sizing132+ UIScale float64 // UI scale for favicon sizing
133- OnNavigate func(url string) // Callback when user navigates via omnibox133+ OnNavigate func(url string) // Callback when user navigates via omnibox
134- OnToast func(message string) // Callback to show toast notification134+ 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 favorites1294+ // 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 return1297 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 return1303 return
1304 }1304 }
1305 1305
@@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() {
1307 ctx := o.ctx1307 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 return1324 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 favorites1345 // 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 return1374 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 list1379+ // 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 repeat1452 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) {
556556
557557 // Create omnibox with pane-specific toast callback
558558 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)
561561 }
562562 omnibox := NewOmnibox(ctx, cfg)
563563 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 callback557 // Create omnibox with pane-specific toast callback
558 cfg := wv.omniboxCfg558 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close