Merge pull request #141 from bnema/feat/keybindings-ui-and-syntax
feat: keybindings UI editor, clean architecture, and config improvements
54 files · +3930 -358
M .mockery.yaml +3 -0
| @@ -10,6 +10,7 @@ packages: | |||||
| 10 | interfaces: | 10 | interfaces: | ||
| 11 | ConfigMigrator: {} | 11 | ConfigMigrator: {} | ||
| 12 | ConfigSchemaProvider: {} | 12 | ConfigSchemaProvider: {} | ||
| 13 | + | ConfigTransformer: {} | |||
| 13 | DatabaseProvider: {} | 14 | DatabaseProvider: {} | ||
| 14 | DiffFormatter: {} | 15 | DiffFormatter: {} | ||
| 15 | SessionLogger: {} | 16 | SessionLogger: {} | ||
| @@ -27,6 +28,8 @@ packages: | |||||
| 27 | FocusedInputProvider: {} | 28 | FocusedInputProvider: {} | ||
| 28 | AccentPickerUI: {} | 29 | AccentPickerUI: {} | ||
| 29 | FontDetector: {} | 30 | FontDetector: {} | ||
| 31 | + | KeybindingsProvider: {} | |||
| 32 | + | KeybindingsSaver: {} | |||
| 30 | github.com/bnema/dumber/internal/domain/repository: | 33 | github.com/bnema/dumber/internal/domain/repository: | ||
| 31 | config: | 34 | config: | ||
| 32 | dir: '{{.InterfaceDir}}/mocks' | 35 | dir: '{{.InterfaceDir}}/mocks' | ||
M CHANGELOG.md +15 -0
| @@ -4,7 +4,22 @@ All notable changes to this project will be documented in this file. | |||||
| 4 | 4 | ||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | + | ### Added | |||
| 8 | + | - **Visual keybindings editor**: New Keybindings tab in `dumb://config` allows editing all keyboard shortcuts via a visual UI. Supports key capture, reset to defaults, and per-binding reset. Fixes #123. | |||
| 9 | + | - **Keybindings hot reload**: Changed keybindings take effect immediately without restarting the browser. | |||
| 10 | + | - **Ordered config file**: Config file sections are now written in alphabetical order for better readability and deterministic output. | |||
| 11 | + | ||||
| 12 | + | ### Changed | |||
| 13 | + | - **Simplified keybinding syntax**: Keybindings now use symbol syntax (`[`, `]`, `{`, `}`) instead of verbose GTK names (`bracketleft`, `braceleft`). Fixes #122. | |||
| 14 | + | - **BREAKING: Global shortcuts config structure**: `workspace.shortcuts` changed from individual string fields to unified `Actions` map with `ActionBinding` (keys + description). Existing custom shortcuts in config.toml will be replaced with defaults. Use the new Keybindings UI to reconfigure. | |||
| 15 | + | - **Keybindings clean architecture**: Refactored keybindings handler to follow ports/adapters pattern. Business logic moved from handler to use cases, config access via gateway adapter instead of global singleton. | |||
| 16 | + | ||||
| 17 | + | ### Upgrade Notes | |||
| 18 | + | > **Recommended**: Delete your `config.toml` and restart dumber to regenerate a fresh config with the new keybinding structure and alphabetically sorted sections. Your settings will reset to defaults, but you can reconfigure keybindings via the new visual editor at `dumb://config` → Keybindings tab. | |||
| 19 | + | ||||
| 7 | ### Fixed | 20 | ### Fixed | ||
| 21 | + | - **Config save/read race condition**: Fixed UI scale and other config values returning stale data after save. Implemented atomic file writes (temp file + rename) and `skipNextReload` flag to prevent fsnotify-triggered reload from overwriting correct in-memory config with stale viper cache. | |||
| 22 | + | - **WebUI lint warnings**: Added ESLint Svelte plugin and resolved 33 lint errors and 14 `any` type warnings across all components. Created `dumber.d.ts` for proper TypeScript types for WebKit bridge communication. | |||
| 8 | - **Session snapshot FK constraint**: Fixed SQLite foreign key constraint violation when snapshot service tried to save before session was persisted to database. Added ready flag and callback notification pattern. | 23 | - **Session snapshot FK constraint**: Fixed SQLite foreign key constraint violation when snapshot service tried to save before session was persisted to database. Added ready flag and callback notification pattern. | ||
| 9 | - **Stacked pane index out of bounds**: Fixed crash when domain model and UI got out of sync during stacked pane operations. Added bounds checking and proper rollback when UI updates fail. | 24 | - **Stacked pane index out of bounds**: Fixed crash when domain model and UI got out of sync during stacked pane operations. Added bounds checking and proper rollback when UI updates fail. | ||
| 10 | - **Noisy gesture navigation logs**: Mouse button back/forward navigation errors (expected when no history) now log at debug level instead of error level. | 25 | - **Noisy gesture navigation logs**: Mouse button back/forward navigation errors (expected when no history) now log at debug level instead of error level. | ||
M docs/CONFIG.md +28 -13
| @@ -236,10 +236,10 @@ move-pane-to-tab = ["m"] | |||||
| 236 | move-pane-to-next-tab = ["M", "shift+m"] | 236 | move-pane-to-next-tab = ["M", "shift+m"] | ||
| 237 | 237 | ||||
| 238 | # Consume-or-expel (niri-style) - very alpha | 238 | # Consume-or-expel (niri-style) - very alpha | ||
| 239 | - | consume-or-expel-left = ["bracketleft", "["] | 239 | + | consume-or-expel-left = ["["] |
| 240 | - | consume-or-expel-right = ["bracketright", "]"] | 240 | + | consume-or-expel-right = ["]"] |
| 241 | - | consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"] | 241 | + | consume-or-expel-up = ["{"] |
| 242 | - | consume-or-expel-down = ["shift+bracketright", "braceright", "}"] | 242 | + | consume-or-expel-down = ["}"] |
| 243 | 243 | ||||
| 244 | focus-right = ["shift+arrowright", "shift+l"] | 244 | focus-right = ["shift+arrowright", "shift+l"] | ||
| 245 | focus-left = ["shift+arrowleft", "shift+h"] | 245 | focus-left = ["shift+arrowleft", "shift+h"] | ||
| @@ -307,16 +307,31 @@ Notes: | |||||
| 307 | 307 | ||||
| 308 | ### Global Shortcuts | 308 | ### Global Shortcuts | ||
| 309 | 309 | ||||
| 310 | - | | Key | Type | Default | Description | | 310 | + | Global shortcuts are configured under `workspace.shortcuts.actions` using the same `ActionBinding` structure as modal keybindings: |
| 311 | - | |-----|------|---------|-------------| | 311 | + | |
| 312 | - | | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) | | 312 | + | | Action | Default Keys | Description | |
| 313 | - | | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | 313 | + | |--------|--------------|-------------| |
| 314 | - | | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | 314 | + | | `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) | |
| 315 | - | | `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) | | 315 | + | | `next_tab` | `ctrl+tab` | Switch to next tab | |
| 316 | - | | `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) | | 316 | + | | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab | |
| 317 | - | | `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) | | 317 | + | | `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked | |
| 318 | - | | `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) | | 318 | + | | `consume_or_expel_right` | `alt+]` | Consume into right sibling stack, or expel right if stacked | |
| 319 | + | | `consume_or_expel_up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked | | |||
| 320 | + | | `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked | | |||
| 319 | 321 | ||||
| 322 | + | **Example:** | |||
| 323 | + | ```toml | |||
| 324 | + | [workspace.shortcuts.actions.close_pane] | |||
| 325 | + | keys = ["ctrl+w"] | |||
| 326 | + | desc = "Close active pane" | |||
| 327 | + | ||||
| 328 | + | [workspace.shortcuts.actions.next_tab] | |||
| 329 | + | keys = ["ctrl+tab", "alt+l"] | |||
| 330 | + | desc = "Switch to next tab" | |||
| 331 | + | ``` | |||
| 332 | + | ||||
| 333 | + | > **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually. | |||
| 334 | + | > | |||
| 320 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | 335 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | ||
| 321 | > | 336 | > | ||
| 322 | > **Note:** Consume-or-expel is experimental and may change behavior between releases. | 337 | > **Note:** Consume-or-expel is experimental and may change behavior between releases. | ||
A internal/application/port/config_transformer.go +9 -0
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | // ConfigTransformer transforms legacy config formats to current format. | |||
| 4 | + | type ConfigTransformer interface { | |||
| 5 | + | // TransformLegacyActions converts old-format action bindings (slices) | |||
| 6 | + | // to new ActionBinding format (map with keys and desc). | |||
| 7 | + | // Modifies the rawConfig map in place. | |||
| 8 | + | TransformLegacyActions(rawConfig map[string]any) | |||
| 9 | + | } | |||
A internal/application/port/keybindings.go +66 -0
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // KeybindingEntry represents a single keybinding for the UI. | |||
| 6 | + | type KeybindingEntry struct { | |||
| 7 | + | Action string `json:"action"` | |||
| 8 | + | Description string `json:"description"` | |||
| 9 | + | Keys []string `json:"keys"` | |||
| 10 | + | DefaultKeys []string `json:"default_keys"` | |||
| 11 | + | IsCustom bool `json:"is_custom"` | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // KeybindingGroup represents keybindings for a mode. | |||
| 15 | + | type KeybindingGroup struct { | |||
| 16 | + | Mode string `json:"mode"` | |||
| 17 | + | DisplayName string `json:"display_name"` | |||
| 18 | + | Bindings []KeybindingEntry `json:"bindings"` | |||
| 19 | + | Activation string `json:"activation,omitempty"` | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // KeybindingsConfig represents all keybindings for the UI. | |||
| 23 | + | type KeybindingsConfig struct { | |||
| 24 | + | Groups []KeybindingGroup `json:"groups"` | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // SetKeybindingRequest represents a request to update a keybinding. | |||
| 28 | + | type SetKeybindingRequest struct { | |||
| 29 | + | RequestID string `json:"requestId"` | |||
| 30 | + | Mode string `json:"mode"` | |||
| 31 | + | Action string `json:"action"` | |||
| 32 | + | Keys []string `json:"keys"` | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ResetKeybindingRequest represents a request to reset a keybinding. | |||
| 36 | + | type ResetKeybindingRequest struct { | |||
| 37 | + | RequestID string `json:"requestId"` | |||
| 38 | + | Mode string `json:"mode"` | |||
| 39 | + | Action string `json:"action"` | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // KeybindingConflict represents a detected conflict. | |||
| 43 | + | type KeybindingConflict struct { | |||
| 44 | + | ConflictingAction string `json:"conflicting_action"` | |||
| 45 | + | ConflictingMode string `json:"conflicting_mode"` | |||
| 46 | + | Key string `json:"key"` | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // SetKeybindingResponse is the response from setting a keybinding. | |||
| 50 | + | type SetKeybindingResponse struct { | |||
| 51 | + | Conflicts []KeybindingConflict `json:"conflicts"` | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // KeybindingsProvider provides keybinding configuration data. | |||
| 55 | + | type KeybindingsProvider interface { | |||
| 56 | + | GetKeybindings(ctx context.Context) (KeybindingsConfig, error) | |||
| 57 | + | GetDefaultKeybindings(ctx context.Context) (KeybindingsConfig, error) | |||
| 58 | + | CheckConflicts(ctx context.Context, mode, action string, keys []string) ([]KeybindingConflict, error) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // KeybindingsSaver persists keybinding changes. | |||
| 62 | + | type KeybindingsSaver interface { | |||
| 63 | + | SetKeybinding(ctx context.Context, req SetKeybindingRequest) error | |||
| 64 | + | ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error | |||
| 65 | + | ResetAllKeybindings(ctx context.Context) error | |||
| 66 | + | } | |||
M internal/application/port/mocks/mocks.go +486 -0
| @@ -1037,6 +1037,73 @@ func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []ent | |||||
| 1037 | return _c | 1037 | return _c | ||
| 1038 | } | 1038 | } | ||
| 1039 | 1039 | ||||
| 1040 | + | // NewMockConfigTransformer creates a new instance of MockConfigTransformer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1041 | + | // The first argument is typically a *testing.T value. | |||
| 1042 | + | func NewMockConfigTransformer(t interface { | |||
| 1043 | + | mock.TestingT | |||
| 1044 | + | Cleanup(func()) | |||
| 1045 | + | }) *MockConfigTransformer { | |||
| 1046 | + | mock := &MockConfigTransformer{} | |||
| 1047 | + | mock.Mock.Test(t) | |||
| 1048 | + | ||||
| 1049 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1050 | + | ||||
| 1051 | + | return mock | |||
| 1052 | + | } | |||
| 1053 | + | ||||
| 1054 | + | // MockConfigTransformer is an autogenerated mock type for the ConfigTransformer type | |||
| 1055 | + | type MockConfigTransformer struct { | |||
| 1056 | + | mock.Mock | |||
| 1057 | + | } | |||
| 1058 | + | ||||
| 1059 | + | type MockConfigTransformer_Expecter struct { | |||
| 1060 | + | mock *mock.Mock | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | func (_m *MockConfigTransformer) EXPECT() *MockConfigTransformer_Expecter { | |||
| 1064 | + | return &MockConfigTransformer_Expecter{mock: &_m.Mock} | |||
| 1065 | + | } | |||
| 1066 | + | ||||
| 1067 | + | // TransformLegacyActions provides a mock function for the type MockConfigTransformer | |||
| 1068 | + | func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |||
| 1069 | + | _mock.Called(rawConfig) | |||
| 1070 | + | return | |||
| 1071 | + | } | |||
| 1072 | + | ||||
| 1073 | + | // MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions' | |||
| 1074 | + | type MockConfigTransformer_TransformLegacyActions_Call struct { | |||
| 1075 | + | *mock.Call | |||
| 1076 | + | } | |||
| 1077 | + | ||||
| 1078 | + | // TransformLegacyActions is a helper method to define mock.On call | |||
| 1079 | + | // - rawConfig map[string]any | |||
| 1080 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1081 | + | return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)} | |||
| 1082 | + | } | |||
| 1083 | + | ||||
| 1084 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1085 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1086 | + | var arg0 map[string]any | |||
| 1087 | + | if args[0] != nil { | |||
| 1088 | + | arg0 = args[0].(map[string]any) | |||
| 1089 | + | } | |||
| 1090 | + | run( | |||
| 1091 | + | arg0, | |||
| 1092 | + | ) | |||
| 1093 | + | }) | |||
| 1094 | + | return _c | |||
| 1095 | + | } | |||
| 1096 | + | ||||
| 1097 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1098 | + | _c.Call.Return() | |||
| 1099 | + | return _c | |||
| 1100 | + | } | |||
| 1101 | + | ||||
| 1102 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1103 | + | _c.Run(run) | |||
| 1104 | + | return _c | |||
| 1105 | + | } | |||
| 1106 | + | ||||
| 1040 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1107 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1041 | // The first argument is typically a *testing.T value. | 1108 | // The first argument is typically a *testing.T value. | ||
| 1042 | func NewMockDatabaseProvider(t interface { | 1109 | func NewMockDatabaseProvider(t interface { | ||
| @@ -1495,6 +1562,425 @@ func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Co | |||||
| 1495 | return _c | 1562 | return _c | ||
| 1496 | } | 1563 | } | ||
| 1497 | 1564 | ||||
| 1565 | + | // NewMockKeybindingsProvider creates a new instance of MockKeybindingsProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1566 | + | // The first argument is typically a *testing.T value. | |||
| 1567 | + | func NewMockKeybindingsProvider(t interface { | |||
| 1568 | + | mock.TestingT | |||
| 1569 | + | Cleanup(func()) | |||
| 1570 | + | }) *MockKeybindingsProvider { | |||
| 1571 | + | mock := &MockKeybindingsProvider{} | |||
| 1572 | + | mock.Mock.Test(t) | |||
| 1573 | + | ||||
| 1574 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1575 | + | ||||
| 1576 | + | return mock | |||
| 1577 | + | } | |||
| 1578 | + | ||||
| 1579 | + | // MockKeybindingsProvider is an autogenerated mock type for the KeybindingsProvider type | |||
| 1580 | + | type MockKeybindingsProvider struct { | |||
| 1581 | + | mock.Mock | |||
| 1582 | + | } | |||
| 1583 | + | ||||
| 1584 | + | type MockKeybindingsProvider_Expecter struct { | |||
| 1585 | + | mock *mock.Mock | |||
| 1586 | + | } | |||
| 1587 | + | ||||
| 1588 | + | func (_m *MockKeybindingsProvider) EXPECT() *MockKeybindingsProvider_Expecter { | |||
| 1589 | + | return &MockKeybindingsProvider_Expecter{mock: &_m.Mock} | |||
| 1590 | + | } | |||
| 1591 | + | ||||
| 1592 | + | // CheckConflicts provides a mock function for the type MockKeybindingsProvider | |||
| 1593 | + | func (_mock *MockKeybindingsProvider) CheckConflicts(ctx context.Context, mode string, action string, keys []string) ([]port.KeybindingConflict, error) { | |||
| 1594 | + | ret := _mock.Called(ctx, mode, action, keys) | |||
| 1595 | + | ||||
| 1596 | + | if len(ret) == 0 { | |||
| 1597 | + | panic("no return value specified for CheckConflicts") | |||
| 1598 | + | } | |||
| 1599 | + | ||||
| 1600 | + | var r0 []port.KeybindingConflict | |||
| 1601 | + | var r1 error | |||
| 1602 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) ([]port.KeybindingConflict, error)); ok { | |||
| 1603 | + | return returnFunc(ctx, mode, action, keys) | |||
| 1604 | + | } | |||
| 1605 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) []port.KeybindingConflict); ok { | |||
| 1606 | + | r0 = returnFunc(ctx, mode, action, keys) | |||
| 1607 | + | } else { | |||
| 1608 | + | if ret.Get(0) != nil { | |||
| 1609 | + | r0 = ret.Get(0).([]port.KeybindingConflict) | |||
| 1610 | + | } | |||
| 1611 | + | } | |||
| 1612 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []string) error); ok { | |||
| 1613 | + | r1 = returnFunc(ctx, mode, action, keys) | |||
| 1614 | + | } else { | |||
| 1615 | + | r1 = ret.Error(1) | |||
| 1616 | + | } | |||
| 1617 | + | return r0, r1 | |||
| 1618 | + | } | |||
| 1619 | + | ||||
| 1620 | + | // MockKeybindingsProvider_CheckConflicts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckConflicts' | |||
| 1621 | + | type MockKeybindingsProvider_CheckConflicts_Call struct { | |||
| 1622 | + | *mock.Call | |||
| 1623 | + | } | |||
| 1624 | + | ||||
| 1625 | + | // CheckConflicts is a helper method to define mock.On call | |||
| 1626 | + | // - ctx context.Context | |||
| 1627 | + | // - mode string | |||
| 1628 | + | // - action string | |||
| 1629 | + | // - keys []string | |||
| 1630 | + | func (_e *MockKeybindingsProvider_Expecter) CheckConflicts(ctx interface{}, mode interface{}, action interface{}, keys interface{}) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1631 | + | return &MockKeybindingsProvider_CheckConflicts_Call{Call: _e.mock.On("CheckConflicts", ctx, mode, action, keys)} | |||
| 1632 | + | } | |||
| 1633 | + | ||||
| 1634 | + | func (_c *MockKeybindingsProvider_CheckConflicts_Call) Run(run func(ctx context.Context, mode string, action string, keys []string)) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1635 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1636 | + | var arg0 context.Context | |||
| 1637 | + | if args[0] != nil { | |||
| 1638 | + | arg0 = args[0].(context.Context) | |||
| 1639 | + | } | |||
| 1640 | + | var arg1 string | |||
| 1641 | + | if args[1] != nil { | |||
| 1642 | + | arg1 = args[1].(string) | |||
| 1643 | + | } | |||
| 1644 | + | var arg2 string | |||
| 1645 | + | if args[2] != nil { | |||
| 1646 | + | arg2 = args[2].(string) | |||
| 1647 | + | } | |||
| 1648 | + | var arg3 []string | |||
| 1649 | + | if args[3] != nil { | |||
| 1650 | + | arg3 = args[3].([]string) | |||
| 1651 | + | } | |||
| 1652 | + | run( | |||
| 1653 | + | arg0, | |||
| 1654 | + | arg1, | |||
| 1655 | + | arg2, | |||
| 1656 | + | arg3, | |||
| 1657 | + | ) | |||
| 1658 | + | }) | |||
| 1659 | + | return _c | |||
| 1660 | + | } | |||
| 1661 | + | ||||
| 1662 | + | func (_c *MockKeybindingsProvider_CheckConflicts_Call) Return(keybindingConflicts []port.KeybindingConflict, err error) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1663 | + | _c.Call.Return(keybindingConflicts, err) | |||
| 1664 | + | return _c | |||
| 1665 | + | } | |||
| 1666 | + | ||||
| 1667 | + | func (_c *MockKeybindingsProvider_CheckConflicts_Call) RunAndReturn(run func(ctx context.Context, mode string, action string, keys []string) ([]port.KeybindingConflict, error)) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1668 | + | _c.Call.Return(run) | |||
| 1669 | + | return _c | |||
| 1670 | + | } | |||
| 1671 | + | ||||
| 1672 | + | // GetDefaultKeybindings provides a mock function for the type MockKeybindingsProvider | |||
| 1673 | + | func (_mock *MockKeybindingsProvider) GetDefaultKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 1674 | + | ret := _mock.Called(ctx) | |||
| 1675 | + | ||||
| 1676 | + | if len(ret) == 0 { | |||
| 1677 | + | panic("no return value specified for GetDefaultKeybindings") | |||
| 1678 | + | } | |||
| 1679 | + | ||||
| 1680 | + | var r0 port.KeybindingsConfig | |||
| 1681 | + | var r1 error | |||
| 1682 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (port.KeybindingsConfig, error)); ok { | |||
| 1683 | + | return returnFunc(ctx) | |||
| 1684 | + | } | |||
| 1685 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.KeybindingsConfig); ok { | |||
| 1686 | + | r0 = returnFunc(ctx) | |||
| 1687 | + | } else { | |||
| 1688 | + | r0 = ret.Get(0).(port.KeybindingsConfig) | |||
| 1689 | + | } | |||
| 1690 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1691 | + | r1 = returnFunc(ctx) | |||
| 1692 | + | } else { | |||
| 1693 | + | r1 = ret.Error(1) | |||
| 1694 | + | } | |||
| 1695 | + | return r0, r1 | |||
| 1696 | + | } | |||
| 1697 | + | ||||
| 1698 | + | // MockKeybindingsProvider_GetDefaultKeybindings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDefaultKeybindings' | |||
| 1699 | + | type MockKeybindingsProvider_GetDefaultKeybindings_Call struct { | |||
| 1700 | + | *mock.Call | |||
| 1701 | + | } | |||
| 1702 | + | ||||
| 1703 | + | // GetDefaultKeybindings is a helper method to define mock.On call | |||
| 1704 | + | // - ctx context.Context | |||
| 1705 | + | func (_e *MockKeybindingsProvider_Expecter) GetDefaultKeybindings(ctx interface{}) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1706 | + | return &MockKeybindingsProvider_GetDefaultKeybindings_Call{Call: _e.mock.On("GetDefaultKeybindings", ctx)} | |||
| 1707 | + | } | |||
| 1708 | + | ||||
| 1709 | + | func (_c *MockKeybindingsProvider_GetDefaultKeybindings_Call) Run(run func(ctx context.Context)) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1710 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1711 | + | var arg0 context.Context | |||
| 1712 | + | if args[0] != nil { | |||
| 1713 | + | arg0 = args[0].(context.Context) | |||
| 1714 | + | } | |||
| 1715 | + | run( | |||
| 1716 | + | arg0, | |||
| 1717 | + | ) | |||
| 1718 | + | }) | |||
| 1719 | + | return _c | |||
| 1720 | + | } | |||
| 1721 | + | ||||
| 1722 | + | func (_c *MockKeybindingsProvider_GetDefaultKeybindings_Call) Return(keybindingsConfig port.KeybindingsConfig, err error) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1723 | + | _c.Call.Return(keybindingsConfig, err) | |||
| 1724 | + | return _c | |||
| 1725 | + | } | |||
| 1726 | + | ||||
| 1727 | + | func (_c *MockKeybindingsProvider_GetDefaultKeybindings_Call) RunAndReturn(run func(ctx context.Context) (port.KeybindingsConfig, error)) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1728 | + | _c.Call.Return(run) | |||
| 1729 | + | return _c | |||
| 1730 | + | } | |||
| 1731 | + | ||||
| 1732 | + | // GetKeybindings provides a mock function for the type MockKeybindingsProvider | |||
| 1733 | + | func (_mock *MockKeybindingsProvider) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 1734 | + | ret := _mock.Called(ctx) | |||
| 1735 | + | ||||
| 1736 | + | if len(ret) == 0 { | |||
| 1737 | + | panic("no return value specified for GetKeybindings") | |||
| 1738 | + | } | |||
| 1739 | + | ||||
| 1740 | + | var r0 port.KeybindingsConfig | |||
| 1741 | + | var r1 error | |||
| 1742 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (port.KeybindingsConfig, error)); ok { | |||
| 1743 | + | return returnFunc(ctx) | |||
| 1744 | + | } | |||
| 1745 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.KeybindingsConfig); ok { | |||
| 1746 | + | r0 = returnFunc(ctx) | |||
| 1747 | + | } else { | |||
| 1748 | + | r0 = ret.Get(0).(port.KeybindingsConfig) | |||
| 1749 | + | } | |||
| 1750 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1751 | + | r1 = returnFunc(ctx) | |||
| 1752 | + | } else { | |||
| 1753 | + | r1 = ret.Error(1) | |||
| 1754 | + | } | |||
| 1755 | + | return r0, r1 | |||
| 1756 | + | } | |||
| 1757 | + | ||||
| 1758 | + | // MockKeybindingsProvider_GetKeybindings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeybindings' | |||
| 1759 | + | type MockKeybindingsProvider_GetKeybindings_Call struct { | |||
| 1760 | + | *mock.Call | |||
| 1761 | + | } | |||
| 1762 | + | ||||
| 1763 | + | // GetKeybindings is a helper method to define mock.On call | |||
| 1764 | + | // - ctx context.Context | |||
| 1765 | + | func (_e *MockKeybindingsProvider_Expecter) GetKeybindings(ctx interface{}) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1766 | + | return &MockKeybindingsProvider_GetKeybindings_Call{Call: _e.mock.On("GetKeybindings", ctx)} | |||
| 1767 | + | } | |||
| 1768 | + | ||||
| 1769 | + | func (_c *MockKeybindingsProvider_GetKeybindings_Call) Run(run func(ctx context.Context)) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1770 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1771 | + | var arg0 context.Context | |||
| 1772 | + | if args[0] != nil { | |||
| 1773 | + | arg0 = args[0].(context.Context) | |||
| 1774 | + | } | |||
| 1775 | + | run( | |||
| 1776 | + | arg0, | |||
| 1777 | + | ) | |||
| 1778 | + | }) | |||
| 1779 | + | return _c | |||
| 1780 | + | } | |||
| 1781 | + | ||||
| 1782 | + | func (_c *MockKeybindingsProvider_GetKeybindings_Call) Return(keybindingsConfig port.KeybindingsConfig, err error) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1783 | + | _c.Call.Return(keybindingsConfig, err) | |||
| 1784 | + | return _c | |||
| 1785 | + | } | |||
| 1786 | + | ||||
| 1787 | + | func (_c *MockKeybindingsProvider_GetKeybindings_Call) RunAndReturn(run func(ctx context.Context) (port.KeybindingsConfig, error)) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1788 | + | _c.Call.Return(run) | |||
| 1789 | + | return _c | |||
| 1790 | + | } | |||
| 1791 | + | ||||
| 1792 | + | // NewMockKeybindingsSaver creates a new instance of MockKeybindingsSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1793 | + | // The first argument is typically a *testing.T value. | |||
| 1794 | + | func NewMockKeybindingsSaver(t interface { | |||
| 1795 | + | mock.TestingT | |||
| 1796 | + | Cleanup(func()) | |||
| 1797 | + | }) *MockKeybindingsSaver { | |||
| 1798 | + | mock := &MockKeybindingsSaver{} | |||
| 1799 | + | mock.Mock.Test(t) | |||
| 1800 | + | ||||
| 1801 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1802 | + | ||||
| 1803 | + | return mock | |||
| 1804 | + | } | |||
| 1805 | + | ||||
| 1806 | + | // MockKeybindingsSaver is an autogenerated mock type for the KeybindingsSaver type | |||
| 1807 | + | type MockKeybindingsSaver struct { | |||
| 1808 | + | mock.Mock | |||
| 1809 | + | } | |||
| 1810 | + | ||||
| 1811 | + | type MockKeybindingsSaver_Expecter struct { | |||
| 1812 | + | mock *mock.Mock | |||
| 1813 | + | } | |||
| 1814 | + | ||||
| 1815 | + | func (_m *MockKeybindingsSaver) EXPECT() *MockKeybindingsSaver_Expecter { | |||
| 1816 | + | return &MockKeybindingsSaver_Expecter{mock: &_m.Mock} | |||
| 1817 | + | } | |||
| 1818 | + | ||||
| 1819 | + | // ResetAllKeybindings provides a mock function for the type MockKeybindingsSaver | |||
| 1820 | + | func (_mock *MockKeybindingsSaver) ResetAllKeybindings(ctx context.Context) error { | |||
| 1821 | + | ret := _mock.Called(ctx) | |||
| 1822 | + | ||||
| 1823 | + | if len(ret) == 0 { | |||
| 1824 | + | panic("no return value specified for ResetAllKeybindings") | |||
| 1825 | + | } | |||
| 1826 | + | ||||
| 1827 | + | var r0 error | |||
| 1828 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |||
| 1829 | + | r0 = returnFunc(ctx) | |||
| 1830 | + | } else { | |||
| 1831 | + | r0 = ret.Error(0) | |||
| 1832 | + | } | |||
| 1833 | + | return r0 | |||
| 1834 | + | } | |||
| 1835 | + | ||||
| 1836 | + | // MockKeybindingsSaver_ResetAllKeybindings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetAllKeybindings' | |||
| 1837 | + | type MockKeybindingsSaver_ResetAllKeybindings_Call struct { | |||
| 1838 | + | *mock.Call | |||
| 1839 | + | } | |||
| 1840 | + | ||||
| 1841 | + | // ResetAllKeybindings is a helper method to define mock.On call | |||
| 1842 | + | // - ctx context.Context | |||
| 1843 | + | func (_e *MockKeybindingsSaver_Expecter) ResetAllKeybindings(ctx interface{}) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1844 | + | return &MockKeybindingsSaver_ResetAllKeybindings_Call{Call: _e.mock.On("ResetAllKeybindings", ctx)} | |||
| 1845 | + | } | |||
| 1846 | + | ||||
| 1847 | + | func (_c *MockKeybindingsSaver_ResetAllKeybindings_Call) Run(run func(ctx context.Context)) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1848 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1849 | + | var arg0 context.Context | |||
| 1850 | + | if args[0] != nil { | |||
| 1851 | + | arg0 = args[0].(context.Context) | |||
| 1852 | + | } | |||
| 1853 | + | run( | |||
| 1854 | + | arg0, | |||
| 1855 | + | ) | |||
| 1856 | + | }) | |||
| 1857 | + | return _c | |||
| 1858 | + | } | |||
| 1859 | + | ||||
| 1860 | + | func (_c *MockKeybindingsSaver_ResetAllKeybindings_Call) Return(err error) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1861 | + | _c.Call.Return(err) | |||
| 1862 | + | return _c | |||
| 1863 | + | } | |||
| 1864 | + | ||||
| 1865 | + | func (_c *MockKeybindingsSaver_ResetAllKeybindings_Call) RunAndReturn(run func(ctx context.Context) error) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1866 | + | _c.Call.Return(run) | |||
| 1867 | + | return _c | |||
| 1868 | + | } | |||
| 1869 | + | ||||
| 1870 | + | // ResetKeybinding provides a mock function for the type MockKeybindingsSaver | |||
| 1871 | + | func (_mock *MockKeybindingsSaver) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | |||
| 1872 | + | ret := _mock.Called(ctx, req) | |||
| 1873 | + | ||||
| 1874 | + | if len(ret) == 0 { | |||
| 1875 | + | panic("no return value specified for ResetKeybinding") | |||
| 1876 | + | } | |||
| 1877 | + | ||||
| 1878 | + | var r0 error | |||
| 1879 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ResetKeybindingRequest) error); ok { | |||
| 1880 | + | r0 = returnFunc(ctx, req) | |||
| 1881 | + | } else { | |||
| 1882 | + | r0 = ret.Error(0) | |||
| 1883 | + | } | |||
| 1884 | + | return r0 | |||
| 1885 | + | } | |||
| 1886 | + | ||||
| 1887 | + | // MockKeybindingsSaver_ResetKeybinding_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetKeybinding' | |||
| 1888 | + | type MockKeybindingsSaver_ResetKeybinding_Call struct { | |||
| 1889 | + | *mock.Call | |||
| 1890 | + | } | |||
| 1891 | + | ||||
| 1892 | + | // ResetKeybinding is a helper method to define mock.On call | |||
| 1893 | + | // - ctx context.Context | |||
| 1894 | + | // - req port.ResetKeybindingRequest | |||
| 1895 | + | func (_e *MockKeybindingsSaver_Expecter) ResetKeybinding(ctx interface{}, req interface{}) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1896 | + | return &MockKeybindingsSaver_ResetKeybinding_Call{Call: _e.mock.On("ResetKeybinding", ctx, req)} | |||
| 1897 | + | } | |||
| 1898 | + | ||||
| 1899 | + | func (_c *MockKeybindingsSaver_ResetKeybinding_Call) Run(run func(ctx context.Context, req port.ResetKeybindingRequest)) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1900 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1901 | + | var arg0 context.Context | |||
| 1902 | + | if args[0] != nil { | |||
| 1903 | + | arg0 = args[0].(context.Context) | |||
| 1904 | + | } | |||
| 1905 | + | var arg1 port.ResetKeybindingRequest | |||
| 1906 | + | if args[1] != nil { | |||
| 1907 | + | arg1 = args[1].(port.ResetKeybindingRequest) | |||
| 1908 | + | } | |||
| 1909 | + | run( | |||
| 1910 | + | arg0, | |||
| 1911 | + | arg1, | |||
| 1912 | + | ) | |||
| 1913 | + | }) | |||
| 1914 | + | return _c | |||
| 1915 | + | } | |||
| 1916 | + | ||||
| 1917 | + | func (_c *MockKeybindingsSaver_ResetKeybinding_Call) Return(err error) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1918 | + | _c.Call.Return(err) | |||
| 1919 | + | return _c | |||
| 1920 | + | } | |||
| 1921 | + | ||||
| 1922 | + | func (_c *MockKeybindingsSaver_ResetKeybinding_Call) RunAndReturn(run func(ctx context.Context, req port.ResetKeybindingRequest) error) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1923 | + | _c.Call.Return(run) | |||
| 1924 | + | return _c | |||
| 1925 | + | } | |||
| 1926 | + | ||||
| 1927 | + | // SetKeybinding provides a mock function for the type MockKeybindingsSaver | |||
| 1928 | + | func (_mock *MockKeybindingsSaver) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) error { | |||
| 1929 | + | ret := _mock.Called(ctx, req) | |||
| 1930 | + | ||||
| 1931 | + | if len(ret) == 0 { | |||
| 1932 | + | panic("no return value specified for SetKeybinding") | |||
| 1933 | + | } | |||
| 1934 | + | ||||
| 1935 | + | var r0 error | |||
| 1936 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.SetKeybindingRequest) error); ok { | |||
| 1937 | + | r0 = returnFunc(ctx, req) | |||
| 1938 | + | } else { | |||
| 1939 | + | r0 = ret.Error(0) | |||
| 1940 | + | } | |||
| 1941 | + | return r0 | |||
| 1942 | + | } | |||
| 1943 | + | ||||
| 1944 | + | // MockKeybindingsSaver_SetKeybinding_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetKeybinding' | |||
| 1945 | + | type MockKeybindingsSaver_SetKeybinding_Call struct { | |||
| 1946 | + | *mock.Call | |||
| 1947 | + | } | |||
| 1948 | + | ||||
| 1949 | + | // SetKeybinding is a helper method to define mock.On call | |||
| 1950 | + | // - ctx context.Context | |||
| 1951 | + | // - req port.SetKeybindingRequest | |||
| 1952 | + | func (_e *MockKeybindingsSaver_Expecter) SetKeybinding(ctx interface{}, req interface{}) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1953 | + | return &MockKeybindingsSaver_SetKeybinding_Call{Call: _e.mock.On("SetKeybinding", ctx, req)} | |||
| 1954 | + | } | |||
| 1955 | + | ||||
| 1956 | + | func (_c *MockKeybindingsSaver_SetKeybinding_Call) Run(run func(ctx context.Context, req port.SetKeybindingRequest)) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1957 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1958 | + | var arg0 context.Context | |||
| 1959 | + | if args[0] != nil { | |||
| 1960 | + | arg0 = args[0].(context.Context) | |||
| 1961 | + | } | |||
| 1962 | + | var arg1 port.SetKeybindingRequest | |||
| 1963 | + | if args[1] != nil { | |||
| 1964 | + | arg1 = args[1].(port.SetKeybindingRequest) | |||
| 1965 | + | } | |||
| 1966 | + | run( | |||
| 1967 | + | arg0, | |||
| 1968 | + | arg1, | |||
| 1969 | + | ) | |||
| 1970 | + | }) | |||
| 1971 | + | return _c | |||
| 1972 | + | } | |||
| 1973 | + | ||||
| 1974 | + | func (_c *MockKeybindingsSaver_SetKeybinding_Call) Return(err error) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1975 | + | _c.Call.Return(err) | |||
| 1976 | + | return _c | |||
| 1977 | + | } | |||
| 1978 | + | ||||
| 1979 | + | func (_c *MockKeybindingsSaver_SetKeybinding_Call) RunAndReturn(run func(ctx context.Context, req port.SetKeybindingRequest) error) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1980 | + | _c.Call.Return(run) | |||
| 1981 | + | return _c | |||
| 1982 | + | } | |||
| 1983 | + | ||||
| 1498 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1984 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1499 | // The first argument is typically a *testing.T value. | 1985 | // The first argument is typically a *testing.T value. | ||
| 1500 | func NewMockSessionLogger(t interface { | 1986 | func NewMockSessionLogger(t interface { | ||
A internal/application/usecase/keybindings.go +140 -0
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // validModes defines the valid keybinding modes. | |||
| 11 | + | var validModes = map[string]bool{ | |||
| 12 | + | "global": true, | |||
| 13 | + | "pane": true, | |||
| 14 | + | "tab": true, | |||
| 15 | + | "resize": true, | |||
| 16 | + | "session": true, | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // GetKeybindingsUseCase retrieves all keybindings. | |||
| 20 | + | type GetKeybindingsUseCase struct { | |||
| 21 | + | provider port.KeybindingsProvider | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewGetKeybindingsUseCase creates a new GetKeybindingsUseCase. | |||
| 25 | + | func NewGetKeybindingsUseCase(provider port.KeybindingsProvider) *GetKeybindingsUseCase { | |||
| 26 | + | return &GetKeybindingsUseCase{provider: provider} | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Execute retrieves all keybindings. | |||
| 30 | + | func (uc *GetKeybindingsUseCase) Execute(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 31 | + | if uc == nil || uc.provider == nil { | |||
| 32 | + | return port.KeybindingsConfig{}, fmt.Errorf("keybindings provider is nil") | |||
| 33 | + | } | |||
| 34 | + | return uc.provider.GetKeybindings(ctx) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // SetKeybindingUseCase updates a single keybinding. | |||
| 38 | + | type SetKeybindingUseCase struct { | |||
| 39 | + | provider port.KeybindingsProvider | |||
| 40 | + | saver port.KeybindingsSaver | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // NewSetKeybindingUseCase creates a new SetKeybindingUseCase. | |||
| 44 | + | func NewSetKeybindingUseCase(provider port.KeybindingsProvider, saver port.KeybindingsSaver) *SetKeybindingUseCase { | |||
| 45 | + | return &SetKeybindingUseCase{provider: provider, saver: saver} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Execute updates a keybinding and returns any conflicts. | |||
| 49 | + | func (uc *SetKeybindingUseCase) Execute(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) { | |||
| 50 | + | if uc == nil || uc.saver == nil { | |||
| 51 | + | return port.SetKeybindingResponse{}, fmt.Errorf("keybindings saver is nil") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | if err := validateSetKeybindingRequest(req); err != nil { | |||
| 55 | + | return port.SetKeybindingResponse{}, err | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Check for conflicts before saving | |||
| 59 | + | var conflicts []port.KeybindingConflict | |||
| 60 | + | if uc.provider != nil { | |||
| 61 | + | var err error | |||
| 62 | + | conflicts, err = uc.provider.CheckConflicts(ctx, req.Mode, req.Action, req.Keys) | |||
| 63 | + | if err != nil { | |||
| 64 | + | return port.SetKeybindingResponse{}, fmt.Errorf("failed to check conflicts: %w", err) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | if err := uc.saver.SetKeybinding(ctx, req); err != nil { | |||
| 69 | + | return port.SetKeybindingResponse{}, err | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | return port.SetKeybindingResponse{Conflicts: conflicts}, nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // ResetKeybindingUseCase resets a keybinding to default. | |||
| 76 | + | type ResetKeybindingUseCase struct { | |||
| 77 | + | saver port.KeybindingsSaver | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // NewResetKeybindingUseCase creates a new ResetKeybindingUseCase. | |||
| 81 | + | func NewResetKeybindingUseCase(saver port.KeybindingsSaver) *ResetKeybindingUseCase { | |||
| 82 | + | return &ResetKeybindingUseCase{saver: saver} | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Execute resets a keybinding to default. | |||
| 86 | + | func (uc *ResetKeybindingUseCase) Execute(ctx context.Context, req port.ResetKeybindingRequest) error { | |||
| 87 | + | if uc == nil || uc.saver == nil { | |||
| 88 | + | return fmt.Errorf("keybindings saver is nil") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | if err := validateResetKeybindingRequest(req); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | return uc.saver.ResetKeybinding(ctx, req) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // ResetAllKeybindingsUseCase resets all keybindings to defaults. | |||
| 99 | + | type ResetAllKeybindingsUseCase struct { | |||
| 100 | + | saver port.KeybindingsSaver | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // NewResetAllKeybindingsUseCase creates a new ResetAllKeybindingsUseCase. | |||
| 104 | + | func NewResetAllKeybindingsUseCase(saver port.KeybindingsSaver) *ResetAllKeybindingsUseCase { | |||
| 105 | + | return &ResetAllKeybindingsUseCase{saver: saver} | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Execute resets all keybindings to defaults. | |||
| 109 | + | func (uc *ResetAllKeybindingsUseCase) Execute(ctx context.Context) error { | |||
| 110 | + | if uc == nil || uc.saver == nil { | |||
| 111 | + | return fmt.Errorf("keybindings saver is nil") | |||
| 112 | + | } | |||
| 113 | + | return uc.saver.ResetAllKeybindings(ctx) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func validateSetKeybindingRequest(req port.SetKeybindingRequest) error { | |||
| 117 | + | if req.Mode == "" { | |||
| 118 | + | return fmt.Errorf("mode is required") | |||
| 119 | + | } | |||
| 120 | + | if req.Action == "" { | |||
| 121 | + | return fmt.Errorf("action is required") | |||
| 122 | + | } | |||
| 123 | + | if !validModes[req.Mode] { | |||
| 124 | + | return fmt.Errorf("invalid mode: %s", req.Mode) | |||
| 125 | + | } | |||
| 126 | + | return nil | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func validateResetKeybindingRequest(req port.ResetKeybindingRequest) error { | |||
| 130 | + | if req.Mode == "" { | |||
| 131 | + | return fmt.Errorf("mode is required") | |||
| 132 | + | } | |||
| 133 | + | if req.Action == "" { | |||
| 134 | + | return fmt.Errorf("action is required") | |||
| 135 | + | } | |||
| 136 | + | if !validModes[req.Mode] { | |||
| 137 | + | return fmt.Errorf("invalid mode: %s", req.Mode) | |||
| 138 | + | } | |||
| 139 | + | return nil | |||
| 140 | + | } | |||
A internal/application/usecase/keybindings_test.go +343 -0
| @@ -0,0 +1,343 @@ | |||||
| 1 | + | package usecase_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 14 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestGetKeybindingsUseCase_Execute(t *testing.T) { | |||
| 18 | + | t.Run("returns keybindings from provider", func(t *testing.T) { | |||
| 19 | + | // Arrange | |||
| 20 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 21 | + | expected := port.KeybindingsConfig{ | |||
| 22 | + | Groups: []port.KeybindingGroup{ | |||
| 23 | + | { | |||
| 24 | + | Mode: "global", | |||
| 25 | + | DisplayName: "Global Shortcuts", | |||
| 26 | + | Bindings: []port.KeybindingEntry{ | |||
| 27 | + | {Action: "close_pane", Keys: []string{"ctrl+w"}}, | |||
| 28 | + | }, | |||
| 29 | + | }, | |||
| 30 | + | }, | |||
| 31 | + | } | |||
| 32 | + | mockProvider.EXPECT().GetKeybindings(mock.Anything).Return(expected, nil) | |||
| 33 | + | ||||
| 34 | + | uc := usecase.NewGetKeybindingsUseCase(mockProvider) | |||
| 35 | + | ||||
| 36 | + | // Act | |||
| 37 | + | result, err := uc.Execute(context.Background()) | |||
| 38 | + | ||||
| 39 | + | // Assert | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | assert.Equal(t, expected, result) | |||
| 42 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |||
| 43 | + | }) | |||
| 44 | + | ||||
| 45 | + | t.Run("returns error when provider fails", func(t *testing.T) { | |||
| 46 | + | // Arrange | |||
| 47 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 48 | + | mockProvider.EXPECT().GetKeybindings(mock.Anything).Return(port.KeybindingsConfig{}, errors.New("provider error")) | |||
| 49 | + | ||||
| 50 | + | uc := usecase.NewGetKeybindingsUseCase(mockProvider) | |||
| 51 | + | ||||
| 52 | + | // Act | |||
| 53 | + | _, err := uc.Execute(context.Background()) | |||
| 54 | + | ||||
| 55 | + | // Assert | |||
| 56 | + | require.Error(t, err) | |||
| 57 | + | assert.Contains(t, err.Error(), "provider error") | |||
| 58 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |||
| 59 | + | }) | |||
| 60 | + | ||||
| 61 | + | t.Run("returns error when provider is nil", func(t *testing.T) { | |||
| 62 | + | // Arrange | |||
| 63 | + | uc := usecase.NewGetKeybindingsUseCase(nil) | |||
| 64 | + | ||||
| 65 | + | // Act | |||
| 66 | + | _, err := uc.Execute(context.Background()) | |||
| 67 | + | ||||
| 68 | + | // Assert | |||
| 69 | + | require.Error(t, err) | |||
| 70 | + | assert.Contains(t, err.Error(), "provider is nil") | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestSetKeybindingUseCase_Execute(t *testing.T) { | |||
| 75 | + | t.Run("successfully sets keybinding", func(t *testing.T) { | |||
| 76 | + | // Arrange | |||
| 77 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 78 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 79 | + | req := port.SetKeybindingRequest{ | |||
| 80 | + | Mode: "pane", | |||
| 81 | + | Action: "split-right", | |||
| 82 | + | Keys: []string{"r"}, | |||
| 83 | + | } | |||
| 84 | + | mockProvider.EXPECT().CheckConflicts(mock.Anything, req.Mode, req.Action, req.Keys).Return(nil, nil) | |||
| 85 | + | mockSaver.EXPECT().SetKeybinding(mock.Anything, req).Return(nil) | |||
| 86 | + | ||||
| 87 | + | uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver) | |||
| 88 | + | ||||
| 89 | + | // Act | |||
| 90 | + | resp, err := uc.Execute(context.Background(), req) | |||
| 91 | + | ||||
| 92 | + | // Assert | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | assert.Empty(t, resp.Conflicts) | |||
| 95 | + | mock.AssertExpectationsForObjects(t, mockProvider, mockSaver) | |||
| 96 | + | }) | |||
| 97 | + | ||||
| 98 | + | t.Run("returns conflicts when detected", func(t *testing.T) { | |||
| 99 | + | // Arrange | |||
| 100 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 101 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 102 | + | req := port.SetKeybindingRequest{ | |||
| 103 | + | Mode: "pane", | |||
| 104 | + | Action: "split-right", | |||
| 105 | + | Keys: []string{"r"}, | |||
| 106 | + | } | |||
| 107 | + | conflicts := []port.KeybindingConflict{ | |||
| 108 | + | {ConflictingAction: "other-action", ConflictingMode: "pane", Key: "r"}, | |||
| 109 | + | } | |||
| 110 | + | mockProvider.EXPECT().CheckConflicts(mock.Anything, req.Mode, req.Action, req.Keys).Return(conflicts, nil) | |||
| 111 | + | mockSaver.EXPECT().SetKeybinding(mock.Anything, req).Return(nil) | |||
| 112 | + | ||||
| 113 | + | uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver) | |||
| 114 | + | ||||
| 115 | + | // Act | |||
| 116 | + | resp, err := uc.Execute(context.Background(), req) | |||
| 117 | + | ||||
| 118 | + | // Assert | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | assert.Equal(t, conflicts, resp.Conflicts) | |||
| 121 | + | mock.AssertExpectationsForObjects(t, mockProvider, mockSaver) | |||
| 122 | + | }) | |||
| 123 | + | ||||
| 124 | + | t.Run("returns error when saver fails", func(t *testing.T) { | |||
| 125 | + | // Arrange | |||
| 126 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 127 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 128 | + | req := port.SetKeybindingRequest{ | |||
| 129 | + | Mode: "pane", | |||
| 130 | + | Action: "split-right", | |||
| 131 | + | Keys: []string{"r"}, | |||
| 132 | + | } | |||
| 133 | + | mockProvider.EXPECT().CheckConflicts(mock.Anything, req.Mode, req.Action, req.Keys).Return(nil, nil) | |||
| 134 | + | mockSaver.EXPECT().SetKeybinding(mock.Anything, req).Return(errors.New("save error")) | |||
| 135 | + | ||||
| 136 | + | uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver) | |||
| 137 | + | ||||
| 138 | + | // Act | |||
| 139 | + | _, err := uc.Execute(context.Background(), req) | |||
| 140 | + | ||||
| 141 | + | // Assert | |||
| 142 | + | require.Error(t, err) | |||
| 143 | + | assert.Contains(t, err.Error(), "save error") | |||
| 144 | + | mock.AssertExpectationsForObjects(t, mockProvider, mockSaver) | |||
| 145 | + | }) | |||
| 146 | + | ||||
| 147 | + | t.Run("returns error when saver is nil", func(t *testing.T) { | |||
| 148 | + | // Arrange | |||
| 149 | + | uc := usecase.NewSetKeybindingUseCase(nil, nil) | |||
| 150 | + | req := port.SetKeybindingRequest{ | |||
| 151 | + | Mode: "pane", | |||
| 152 | + | Action: "split-right", | |||
| 153 | + | Keys: []string{"r"}, | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Act | |||
| 157 | + | _, err := uc.Execute(context.Background(), req) | |||
| 158 | + | ||||
| 159 | + | // Assert | |||
| 160 | + | require.Error(t, err) | |||
| 161 | + | assert.Contains(t, err.Error(), "saver is nil") | |||
| 162 | + | }) | |||
| 163 | + | ||||
| 164 | + | t.Run("returns error when mode is empty", func(t *testing.T) { | |||
| 165 | + | // Arrange | |||
| 166 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 167 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 168 | + | req := port.SetKeybindingRequest{ | |||
| 169 | + | Mode: "", | |||
| 170 | + | Action: "split-right", | |||
| 171 | + | Keys: []string{"r"}, | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver) | |||
| 175 | + | ||||
| 176 | + | // Act | |||
| 177 | + | _, err := uc.Execute(context.Background(), req) | |||
| 178 | + | ||||
| 179 | + | // Assert | |||
| 180 | + | require.Error(t, err) | |||
| 181 | + | assert.Contains(t, err.Error(), "mode is required") | |||
| 182 | + | }) | |||
| 183 | + | ||||
| 184 | + | t.Run("returns error when action is empty", func(t *testing.T) { | |||
| 185 | + | // Arrange | |||
| 186 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 187 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 188 | + | req := port.SetKeybindingRequest{ | |||
| 189 | + | Mode: "pane", | |||
| 190 | + | Action: "", | |||
| 191 | + | Keys: []string{"r"}, | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver) | |||
| 195 | + | ||||
| 196 | + | // Act | |||
| 197 | + | _, err := uc.Execute(context.Background(), req) | |||
| 198 | + | ||||
| 199 | + | // Assert | |||
| 200 | + | require.Error(t, err) | |||
| 201 | + | assert.Contains(t, err.Error(), "action is required") | |||
| 202 | + | }) | |||
| 203 | + | ||||
| 204 | + | t.Run("returns error for invalid mode", func(t *testing.T) { | |||
| 205 | + | // Arrange | |||
| 206 | + | mockProvider := mocks.NewMockKeybindingsProvider(t) | |||
| 207 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 208 | + | req := port.SetKeybindingRequest{ | |||
| 209 | + | Mode: "invalid", | |||
| 210 | + | Action: "split-right", | |||
| 211 | + | Keys: []string{"r"}, | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver) | |||
| 215 | + | ||||
| 216 | + | // Act | |||
| 217 | + | _, err := uc.Execute(context.Background(), req) | |||
| 218 | + | ||||
| 219 | + | // Assert | |||
| 220 | + | require.Error(t, err) | |||
| 221 | + | assert.Contains(t, err.Error(), "invalid mode") | |||
| 222 | + | }) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestResetKeybindingUseCase_Execute(t *testing.T) { | |||
| 226 | + | t.Run("successfully resets keybinding", func(t *testing.T) { | |||
| 227 | + | // Arrange | |||
| 228 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 229 | + | req := port.ResetKeybindingRequest{ | |||
| 230 | + | Mode: "pane", | |||
| 231 | + | Action: "split-right", | |||
| 232 | + | } | |||
| 233 | + | mockSaver.EXPECT().ResetKeybinding(mock.Anything, req).Return(nil) | |||
| 234 | + | ||||
| 235 | + | uc := usecase.NewResetKeybindingUseCase(mockSaver) | |||
| 236 | + | ||||
| 237 | + | // Act | |||
| 238 | + | err := uc.Execute(context.Background(), req) | |||
| 239 | + | ||||
| 240 | + | // Assert | |||
| 241 | + | require.NoError(t, err) | |||
| 242 | + | mock.AssertExpectationsForObjects(t, mockSaver) | |||
| 243 | + | }) | |||
| 244 | + | ||||
| 245 | + | t.Run("returns error when saver fails", func(t *testing.T) { | |||
| 246 | + | // Arrange | |||
| 247 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 248 | + | req := port.ResetKeybindingRequest{ | |||
| 249 | + | Mode: "pane", | |||
| 250 | + | Action: "split-right", | |||
| 251 | + | } | |||
| 252 | + | mockSaver.EXPECT().ResetKeybinding(mock.Anything, req).Return(errors.New("reset error")) | |||
| 253 | + | ||||
| 254 | + | uc := usecase.NewResetKeybindingUseCase(mockSaver) | |||
| 255 | + | ||||
| 256 | + | // Act | |||
| 257 | + | err := uc.Execute(context.Background(), req) | |||
| 258 | + | ||||
| 259 | + | // Assert | |||
| 260 | + | require.Error(t, err) | |||
| 261 | + | assert.Contains(t, err.Error(), "reset error") | |||
| 262 | + | mock.AssertExpectationsForObjects(t, mockSaver) | |||
| 263 | + | }) | |||
| 264 | + | ||||
| 265 | + | t.Run("returns error when saver is nil", func(t *testing.T) { | |||
| 266 | + | // Arrange | |||
| 267 | + | uc := usecase.NewResetKeybindingUseCase(nil) | |||
| 268 | + | req := port.ResetKeybindingRequest{ | |||
| 269 | + | Mode: "pane", | |||
| 270 | + | Action: "split-right", | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // Act | |||
| 274 | + | err := uc.Execute(context.Background(), req) | |||
| 275 | + | ||||
| 276 | + | // Assert | |||
| 277 | + | require.Error(t, err) | |||
| 278 | + | assert.Contains(t, err.Error(), "saver is nil") | |||
| 279 | + | }) | |||
| 280 | + | ||||
| 281 | + | t.Run("returns error when mode is empty", func(t *testing.T) { | |||
| 282 | + | // Arrange | |||
| 283 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 284 | + | req := port.ResetKeybindingRequest{ | |||
| 285 | + | Mode: "", | |||
| 286 | + | Action: "split-right", | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | uc := usecase.NewResetKeybindingUseCase(mockSaver) | |||
| 290 | + | ||||
| 291 | + | // Act | |||
| 292 | + | err := uc.Execute(context.Background(), req) | |||
| 293 | + | ||||
| 294 | + | // Assert | |||
| 295 | + | require.Error(t, err) | |||
| 296 | + | assert.Contains(t, err.Error(), "mode is required") | |||
| 297 | + | }) | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestResetAllKeybindingsUseCase_Execute(t *testing.T) { | |||
| 301 | + | t.Run("successfully resets all keybindings", func(t *testing.T) { | |||
| 302 | + | // Arrange | |||
| 303 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 304 | + | mockSaver.EXPECT().ResetAllKeybindings(mock.Anything).Return(nil) | |||
| 305 | + | ||||
| 306 | + | uc := usecase.NewResetAllKeybindingsUseCase(mockSaver) | |||
| 307 | + | ||||
| 308 | + | // Act | |||
| 309 | + | err := uc.Execute(context.Background()) | |||
| 310 | + | ||||
| 311 | + | // Assert | |||
| 312 | + | require.NoError(t, err) | |||
| 313 | + | mock.AssertExpectationsForObjects(t, mockSaver) | |||
| 314 | + | }) | |||
| 315 | + | ||||
| 316 | + | t.Run("returns error when saver fails", func(t *testing.T) { | |||
| 317 | + | // Arrange | |||
| 318 | + | mockSaver := mocks.NewMockKeybindingsSaver(t) | |||
| 319 | + | mockSaver.EXPECT().ResetAllKeybindings(mock.Anything).Return(errors.New("reset all error")) | |||
| 320 | + | ||||
| 321 | + | uc := usecase.NewResetAllKeybindingsUseCase(mockSaver) | |||
| 322 | + | ||||
| 323 | + | // Act | |||
| 324 | + | err := uc.Execute(context.Background()) | |||
| 325 | + | ||||
| 326 | + | // Assert | |||
| 327 | + | require.Error(t, err) | |||
| 328 | + | assert.Contains(t, err.Error(), "reset all error") | |||
| 329 | + | mock.AssertExpectationsForObjects(t, mockSaver) | |||
| 330 | + | }) | |||
| 331 | + | ||||
| 332 | + | t.Run("returns error when saver is nil", func(t *testing.T) { | |||
| 333 | + | // Arrange | |||
| 334 | + | uc := usecase.NewResetAllKeybindingsUseCase(nil) | |||
| 335 | + | ||||
| 336 | + | // Act | |||
| 337 | + | err := uc.Execute(context.Background()) | |||
| 338 | + | ||||
| 339 | + | // Assert | |||
| 340 | + | require.Error(t, err) | |||
| 341 | + | assert.Contains(t, err.Error(), "saver is nil") | |||
| 342 | + | }) | |||
| 343 | + | } | |||
A internal/application/usecase/transform_legacy_config.go +18 -0
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |||
| 4 | + | ||||
| 5 | + | // TransformLegacyConfigUseCase handles legacy config format transformation. | |||
| 6 | + | type TransformLegacyConfigUseCase struct { | |||
| 7 | + | transformer port.ConfigTransformer | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | // NewTransformLegacyConfigUseCase creates a new use case instance. | |||
| 11 | + | func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *TransformLegacyConfigUseCase { | |||
| 12 | + | return &TransformLegacyConfigUseCase{transformer: transformer} | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // Execute transforms legacy config format in place. | |||
| 16 | + | func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) { | |||
| 17 | + | uc.transformer.TransformLegacyActions(rawConfig) | |||
| 18 | + | } | |||
A internal/application/usecase/transform_legacy_config_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 7 | + | "github.com/stretchr/testify/mock" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestTransformLegacyConfigUseCase_Execute(t *testing.T) { | |||
| 11 | + | mockTransformer := mocks.NewMockConfigTransformer(t) | |||
| 12 | + | ||||
| 13 | + | rawConfig := map[string]any{"test": "data"} | |||
| 14 | + | ||||
| 15 | + | mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return() | |||
| 16 | + | ||||
| 17 | + | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |||
| 18 | + | uc.Execute(rawConfig) | |||
| 19 | + | ||||
| 20 | + | // AssertExpectations is called automatically via t.Cleanup | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) { | |||
| 24 | + | mockTransformer := mocks.NewMockConfigTransformer(t) | |||
| 25 | + | ||||
| 26 | + | rawConfig := map[string]any{ | |||
| 27 | + | "workspace": map[string]any{ | |||
| 28 | + | "pane_mode": map[string]any{ | |||
| 29 | + | "actions": map[string]any{ | |||
| 30 | + | "focus-left": []any{"h"}, | |||
| 31 | + | }, | |||
| 32 | + | }, | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Verify the transformer receives the exact rawConfig | |||
| 37 | + | mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return() | |||
| 38 | + | ||||
| 39 | + | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |||
| 40 | + | uc.Execute(rawConfig) | |||
| 41 | + | } | |||
M internal/infrastructure/config/defaults.go +53 -52
| @@ -161,40 +161,40 @@ func DefaultConfig() *Config { | |||||
| 161 | PaneMode: PaneModeConfig{ | 161 | PaneMode: PaneModeConfig{ | ||
| 162 | ActivationShortcut: defaultPaneActivationShortcut, | 162 | ActivationShortcut: defaultPaneActivationShortcut, | ||
| 163 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | 163 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | ||
| 164 | - | Actions: map[string][]string{ | 164 | + | Actions: map[string]ActionBinding{ |
| 165 | - | "split-right": {"arrowright", "r"}, | 165 | + | "split-right": {Keys: []string{"arrowright", "r"}, Desc: "Split pane to the right"}, |
| 166 | - | "split-left": {"arrowleft", "l"}, | 166 | + | "split-left": {Keys: []string{"arrowleft", "l"}, Desc: "Split pane to the left"}, |
| 167 | - | "split-up": {"arrowup", "u"}, | 167 | + | "split-up": {Keys: []string{"arrowup", "u"}, Desc: "Split pane upward"}, |
| 168 | - | "split-down": {"arrowdown", "d"}, | 168 | + | "split-down": {Keys: []string{"arrowdown", "d"}, Desc: "Split pane downward"}, |
| 169 | - | "stack-pane": {"s"}, | 169 | + | "stack-pane": {Keys: []string{"s"}, Desc: "Stack pane with sibling"}, |
| 170 | - | "close-pane": {"x"}, | 170 | + | "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"}, |
| 171 | - | "move-pane-to-tab": {"m"}, | 171 | + | "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"}, |
| 172 | - | "move-pane-to-next-tab": {"M", "shift+m"}, | 172 | + | "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}, Desc: "Move pane to next tab"}, |
| 173 | 173 | ||||
| 174 | - | "consume-or-expel-left": {"bracketleft", "["}, | 174 | + | "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"}, |
| 175 | - | "consume-or-expel-right": {"bracketright", "]"}, | 175 | + | "consume-or-expel-right": {Keys: []string{"]"}, Desc: "Consume/expel pane right"}, |
| 176 | - | "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"}, | 176 | + | "consume-or-expel-up": {Keys: []string{"{"}, Desc: "Consume/expel pane up"}, |
| 177 | - | "consume-or-expel-down": {"shift+bracketright", "braceright", "}"}, | 177 | + | "consume-or-expel-down": {Keys: []string{"}"}, Desc: "Consume/expel pane down"}, |
| 178 | 178 | ||||
| 179 | - | "focus-right": {"shift+arrowright", "shift+l"}, | 179 | + | "focus-right": {Keys: []string{"shift+arrowright", "shift+l"}, Desc: "Focus pane to the right"}, |
| 180 | - | "focus-left": {"shift+arrowleft", "shift+h"}, | 180 | + | "focus-left": {Keys: []string{"shift+arrowleft", "shift+h"}, Desc: "Focus pane to the left"}, |
| 181 | - | "focus-up": {"shift+arrowup", "shift+k"}, | 181 | + | "focus-up": {Keys: []string{"shift+arrowup", "shift+k"}, Desc: "Focus pane above"}, |
| 182 | - | "focus-down": {"shift+arrowdown", "shift+j"}, | 182 | + | "focus-down": {Keys: []string{"shift+arrowdown", "shift+j"}, Desc: "Focus pane below"}, |
| 183 | - | "confirm": {"enter"}, | 183 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 184 | - | "cancel": {"escape"}, | 184 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 185 | }, | 185 | }, | ||
| 186 | }, | 186 | }, | ||
| 187 | TabMode: TabModeConfig{ | 187 | TabMode: TabModeConfig{ | ||
| 188 | ActivationShortcut: defaultTabActivationShortcut, | 188 | ActivationShortcut: defaultTabActivationShortcut, | ||
| 189 | TimeoutMilliseconds: defaultTabTimeoutMilliseconds, | 189 | TimeoutMilliseconds: defaultTabTimeoutMilliseconds, | ||
| 190 | - | Actions: map[string][]string{ | 190 | + | Actions: map[string]ActionBinding{ |
| 191 | - | "new-tab": {"n", "c"}, | 191 | + | "new-tab": {Keys: []string{"n", "c"}, Desc: "Create new tab"}, |
| 192 | - | "close-tab": {"x"}, | 192 | + | "close-tab": {Keys: []string{"x"}, Desc: "Close current tab"}, |
| 193 | - | "next-tab": {"l", "tab"}, | 193 | + | "next-tab": {Keys: []string{"l", "tab"}, Desc: "Switch to next tab"}, |
| 194 | - | "previous-tab": {"h", "shift+tab"}, | 194 | + | "previous-tab": {Keys: []string{"h", "shift+tab"}, Desc: "Switch to previous tab"}, |
| 195 | - | "rename-tab": {"r"}, | 195 | + | "rename-tab": {Keys: []string{"r"}, Desc: "Rename current tab"}, |
| 196 | - | "confirm": {"enter"}, | 196 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 197 | - | "cancel": {"escape"}, | 197 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 198 | }, | 198 | }, | ||
| 199 | }, | 199 | }, | ||
| 200 | ResizeMode: ResizeModeConfig{ | 200 | ResizeMode: ResizeModeConfig{ | ||
| @@ -202,30 +202,31 @@ func DefaultConfig() *Config { | |||||
| 202 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | 202 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | ||
| 203 | StepPercent: defaultResizeStepPercent, | 203 | StepPercent: defaultResizeStepPercent, | ||
| 204 | MinPanePercent: defaultResizeMinPanePercent, | 204 | MinPanePercent: defaultResizeMinPanePercent, | ||
| 205 | - | Actions: map[string][]string{ | 205 | + | Actions: map[string]ActionBinding{ |
| 206 | - | "resize-increase-left": {"h", "arrowleft"}, | 206 | + | "resize-increase-left": {Keys: []string{"h", "arrowleft"}, Desc: "Increase size leftward"}, |
| 207 | - | "resize-increase-down": {"j", "arrowdown"}, | 207 | + | "resize-increase-down": {Keys: []string{"j", "arrowdown"}, Desc: "Increase size downward"}, |
| 208 | - | "resize-increase-up": {"k", "arrowup"}, | 208 | + | "resize-increase-up": {Keys: []string{"k", "arrowup"}, Desc: "Increase size upward"}, |
| 209 | - | "resize-increase-right": {"l", "arrowright"}, | 209 | + | "resize-increase-right": {Keys: []string{"l", "arrowright"}, Desc: "Increase size rightward"}, |
| 210 | - | "resize-decrease-left": {"H"}, | 210 | + | "resize-decrease-left": {Keys: []string{"H"}, Desc: "Decrease size leftward"}, |
| 211 | - | "resize-decrease-down": {"J"}, | 211 | + | "resize-decrease-down": {Keys: []string{"J"}, Desc: "Decrease size downward"}, |
| 212 | - | "resize-decrease-up": {"K"}, | 212 | + | "resize-decrease-up": {Keys: []string{"K"}, Desc: "Decrease size upward"}, |
| 213 | - | "resize-decrease-right": {"L"}, | 213 | + | "resize-decrease-right": {Keys: []string{"L"}, Desc: "Decrease size rightward"}, |
| 214 | - | "resize-increase": {"+", "="}, | 214 | + | "resize-increase": {Keys: []string{"+", "="}, Desc: "Increase pane size"}, |
| 215 | - | "resize-decrease": {"-"}, | 215 | + | "resize-decrease": {Keys: []string{"-"}, Desc: "Decrease pane size"}, |
| 216 | - | "confirm": {"enter"}, | 216 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 217 | - | "cancel": {"escape"}, | 217 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 218 | }, | 218 | }, | ||
| 219 | }, | 219 | }, | ||
| 220 | Shortcuts: GlobalShortcutsConfig{ | 220 | Shortcuts: GlobalShortcutsConfig{ | ||
| 221 | - | ClosePane: "ctrl+w", | 221 | + | Actions: map[string]ActionBinding{ |
| 222 | - | NextTab: "ctrl+tab", | 222 | + | "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, |
| 223 | - | PreviousTab: "ctrl+shift+tab", | 223 | + | "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, |
| 224 | - | 224 | + | "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, | |
| 225 | - | ConsumeOrExpelLeft: "alt+bracketleft", | 225 | + | "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, |
| 226 | - | ConsumeOrExpelRight: "alt+bracketright", | 226 | + | "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, |
| 227 | - | ConsumeOrExpelUp: "alt+shift+bracketleft", | 227 | + | "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, |
| 228 | - | ConsumeOrExpelDown: "alt+shift+bracketright", | 228 | + | "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, |
| 229 | + | }, | |||
| 229 | }, | 230 | }, | ||
| 230 | TabBarPosition: defaultTabBarPosition, | 231 | TabBarPosition: defaultTabBarPosition, | ||
| 231 | HideTabBarWhenSingleTab: true, | 232 | HideTabBarWhenSingleTab: true, | ||
| @@ -269,10 +270,10 @@ func DefaultConfig() *Config { | |||||
| 269 | SessionMode: SessionModeConfig{ | 270 | SessionMode: SessionModeConfig{ | ||
| 270 | ActivationShortcut: defaultSessionActivationShortcut, | 271 | ActivationShortcut: defaultSessionActivationShortcut, | ||
| 271 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | 272 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | ||
| 272 | - | Actions: map[string][]string{ | 273 | + | Actions: map[string]ActionBinding{ |
| 273 | - | "session-manager": {"s", "w"}, | 274 | + | "session-manager": {Keys: []string{"s", "w"}, Desc: "Open session manager"}, |
| 274 | - | "confirm": {"enter"}, | 275 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 275 | - | "cancel": {"escape"}, | 276 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 276 | }, | 277 | }, | ||
| 277 | }, | 278 | }, | ||
| 278 | }, | 279 | }, | ||
A internal/infrastructure/config/keybindings_gateway.go +285 -0
| @@ -0,0 +1,285 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "reflect" | |||
| 6 | + | "sort" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const modeGlobal = "global" | |||
| 13 | + | ||||
| 14 | + | // KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver. | |||
| 15 | + | type KeybindingsGateway struct { | |||
| 16 | + | mgr *Manager | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NewKeybindingsGateway creates a new KeybindingsGateway. | |||
| 20 | + | func NewKeybindingsGateway(mgr *Manager) *KeybindingsGateway { | |||
| 21 | + | return &KeybindingsGateway{mgr: mgr} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // GetKeybindings returns all keybindings grouped by mode. | |||
| 25 | + | func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 26 | + | log := logging.FromContext(ctx) | |||
| 27 | + | cfg := g.mgr.Get() | |||
| 28 | + | defaults := DefaultConfig() | |||
| 29 | + | log.Debug().Msg("keybindings gateway: fetching keybindings") | |||
| 30 | + | ||||
| 31 | + | groups := []port.KeybindingGroup{ | |||
| 32 | + | g.buildGlobalGroup(cfg, defaults), | |||
| 33 | + | g.buildPaneModeGroup(cfg, defaults), | |||
| 34 | + | g.buildTabModeGroup(cfg, defaults), | |||
| 35 | + | g.buildResizeModeGroup(cfg, defaults), | |||
| 36 | + | g.buildSessionModeGroup(cfg, defaults), | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | return port.KeybindingsConfig{Groups: groups}, nil | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // GetDefaultKeybindings returns the default keybindings configuration. | |||
| 43 | + | func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 44 | + | log := logging.FromContext(ctx) | |||
| 45 | + | defaults := DefaultConfig() | |||
| 46 | + | ||||
| 47 | + | groups := []port.KeybindingGroup{ | |||
| 48 | + | g.buildGlobalGroup(defaults, defaults), | |||
| 49 | + | g.buildPaneModeGroup(defaults, defaults), | |||
| 50 | + | g.buildTabModeGroup(defaults, defaults), | |||
| 51 | + | g.buildResizeModeGroup(defaults, defaults), | |||
| 52 | + | g.buildSessionModeGroup(defaults, defaults), | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | log.Debug().Int("groups", len(groups)).Msg("keybindings gateway: returning default keybindings") | |||
| 56 | + | return port.KeybindingsConfig{Groups: groups}, nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // CheckConflicts scans all modes for keybinding conflicts. | |||
| 60 | + | func (g *KeybindingsGateway) CheckConflicts(ctx context.Context, mode, action string, keys []string) ([]port.KeybindingConflict, error) { | |||
| 61 | + | log := logging.FromContext(ctx) | |||
| 62 | + | cfg := g.mgr.Get() | |||
| 63 | + | ||||
| 64 | + | conflicts := g.checkConflicts(cfg, mode, action, keys) | |||
| 65 | + | if len(conflicts) > 0 { | |||
| 66 | + | log.Warn().Int("conflicts", len(conflicts)).Msg("keybindings gateway: conflicts detected") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | return conflicts, nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // SetKeybinding updates a single keybinding. | |||
| 73 | + | func (g *KeybindingsGateway) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) error { | |||
| 74 | + | log := logging.FromContext(ctx) | |||
| 75 | + | log.Debug().Str("mode", req.Mode).Str("action", req.Action).Msg("keybindings gateway: setting keybinding") | |||
| 76 | + | cfg := g.mgr.Get() | |||
| 77 | + | g.updateKeybinding(cfg, req) | |||
| 78 | + | return g.mgr.Save(cfg) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // ResetKeybinding resets a keybinding to its default value. | |||
| 82 | + | func (g *KeybindingsGateway) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | |||
| 83 | + | log := logging.FromContext(ctx) | |||
| 84 | + | log.Info().Str("mode", req.Mode).Str("action", req.Action).Msg("keybindings gateway: resetting keybinding to default") | |||
| 85 | + | ||||
| 86 | + | defaults := DefaultConfig() | |||
| 87 | + | defaultKeys := g.getDefaultKeys(defaults, req.Mode, req.Action) | |||
| 88 | + | ||||
| 89 | + | setReq := port.SetKeybindingRequest{ | |||
| 90 | + | Mode: req.Mode, | |||
| 91 | + | Action: req.Action, | |||
| 92 | + | Keys: defaultKeys, | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | cfg := g.mgr.Get() | |||
| 96 | + | g.updateKeybinding(cfg, setReq) | |||
| 97 | + | ||||
| 98 | + | return g.mgr.Save(cfg) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // ResetAllKeybindings resets all keybindings to their default values. | |||
| 102 | + | func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error { | |||
| 103 | + | log := logging.FromContext(ctx) | |||
| 104 | + | log.Info().Msg("keybindings gateway: resetting all keybindings to defaults") | |||
| 105 | + | ||||
| 106 | + | cfg := g.mgr.Get() | |||
| 107 | + | defaults := DefaultConfig() | |||
| 108 | + | ||||
| 109 | + | cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions | |||
| 110 | + | cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions | |||
| 111 | + | cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions | |||
| 112 | + | cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts | |||
| 113 | + | cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions | |||
| 114 | + | ||||
| 115 | + | return g.mgr.Save(cfg) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // buildGlobalGroup builds the global shortcuts group. | |||
| 119 | + | func (g *KeybindingsGateway) buildGlobalGroup(cfg, defaults *Config) port.KeybindingGroup { | |||
| 120 | + | return port.KeybindingGroup{ | |||
| 121 | + | Mode: modeGlobal, | |||
| 122 | + | DisplayName: "Global Shortcuts", | |||
| 123 | + | Bindings: g.buildModeBindings(cfg.Workspace.Shortcuts.Actions, defaults.Workspace.Shortcuts.Actions), | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // buildPaneModeGroup builds the pane mode group. | |||
| 128 | + | func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |||
| 129 | + | return port.KeybindingGroup{ | |||
| 130 | + | Mode: "pane", | |||
| 131 | + | DisplayName: "Pane Mode", | |||
| 132 | + | Bindings: g.buildModeBindings(cfg.Workspace.PaneMode.Actions, defaults.Workspace.PaneMode.Actions), | |||
| 133 | + | Activation: cfg.Workspace.PaneMode.ActivationShortcut, | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // buildTabModeGroup builds the tab mode group. | |||
| 138 | + | func (g *KeybindingsGateway) buildTabModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |||
| 139 | + | return port.KeybindingGroup{ | |||
| 140 | + | Mode: "tab", | |||
| 141 | + | DisplayName: "Tab Mode", | |||
| 142 | + | Bindings: g.buildModeBindings(cfg.Workspace.TabMode.Actions, defaults.Workspace.TabMode.Actions), | |||
| 143 | + | Activation: cfg.Workspace.TabMode.ActivationShortcut, | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // buildResizeModeGroup builds the resize mode group. | |||
| 148 | + | func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |||
| 149 | + | return port.KeybindingGroup{ | |||
| 150 | + | Mode: "resize", | |||
| 151 | + | DisplayName: "Resize Mode", | |||
| 152 | + | Bindings: g.buildModeBindings(cfg.Workspace.ResizeMode.Actions, defaults.Workspace.ResizeMode.Actions), | |||
| 153 | + | Activation: cfg.Workspace.ResizeMode.ActivationShortcut, | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // buildSessionModeGroup builds the session mode group. | |||
| 158 | + | func (g *KeybindingsGateway) buildSessionModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |||
| 159 | + | return port.KeybindingGroup{ | |||
| 160 | + | Mode: "session", | |||
| 161 | + | DisplayName: "Session Mode", | |||
| 162 | + | Bindings: g.buildModeBindings(cfg.Session.SessionMode.Actions, defaults.Session.SessionMode.Actions), | |||
| 163 | + | Activation: cfg.Session.SessionMode.ActivationShortcut, | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // buildModeBindings builds keybinding entries for a modal mode. | |||
| 168 | + | func (*KeybindingsGateway) buildModeBindings(actions, defaultActions map[string]ActionBinding) []port.KeybindingEntry { | |||
| 169 | + | var entries []port.KeybindingEntry | |||
| 170 | + | ||||
| 171 | + | actionNames := make([]string, 0, len(actions)) | |||
| 172 | + | for action := range actions { | |||
| 173 | + | actionNames = append(actionNames, action) | |||
| 174 | + | } | |||
| 175 | + | sort.Strings(actionNames) | |||
| 176 | + | ||||
| 177 | + | for _, action := range actionNames { | |||
| 178 | + | binding := actions[action] | |||
| 179 | + | defaultBinding := defaultActions[action] | |||
| 180 | + | ||||
| 181 | + | entries = append(entries, port.KeybindingEntry{ | |||
| 182 | + | Action: action, | |||
| 183 | + | Description: binding.Desc, | |||
| 184 | + | Keys: binding.Keys, | |||
| 185 | + | DefaultKeys: defaultBinding.Keys, | |||
| 186 | + | IsCustom: !reflect.DeepEqual(binding.Keys, defaultBinding.Keys), | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | return entries | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // updateKeybinding updates a keybinding in the config. | |||
| 194 | + | func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingRequest) { | |||
| 195 | + | switch req.Mode { | |||
| 196 | + | case modeGlobal: | |||
| 197 | + | if existing, ok := cfg.Workspace.Shortcuts.Actions[req.Action]; ok { | |||
| 198 | + | existing.Keys = req.Keys | |||
| 199 | + | cfg.Workspace.Shortcuts.Actions[req.Action] = existing | |||
| 200 | + | } | |||
| 201 | + | case "pane": | |||
| 202 | + | if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok { | |||
| 203 | + | existing.Keys = req.Keys | |||
| 204 | + | cfg.Workspace.PaneMode.Actions[req.Action] = existing | |||
| 205 | + | } | |||
| 206 | + | case "tab": | |||
| 207 | + | if existing, ok := cfg.Workspace.TabMode.Actions[req.Action]; ok { | |||
| 208 | + | existing.Keys = req.Keys | |||
| 209 | + | cfg.Workspace.TabMode.Actions[req.Action] = existing | |||
| 210 | + | } | |||
| 211 | + | case "resize": | |||
| 212 | + | if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok { | |||
| 213 | + | existing.Keys = req.Keys | |||
| 214 | + | cfg.Workspace.ResizeMode.Actions[req.Action] = existing | |||
| 215 | + | } | |||
| 216 | + | case "session": | |||
| 217 | + | if existing, ok := cfg.Session.SessionMode.Actions[req.Action]; ok { | |||
| 218 | + | existing.Keys = req.Keys | |||
| 219 | + | cfg.Session.SessionMode.Actions[req.Action] = existing | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // getDefaultKeys returns the default keys for an action. | |||
| 225 | + | func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string) []string { | |||
| 226 | + | switch mode { | |||
| 227 | + | case modeGlobal: | |||
| 228 | + | if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok { | |||
| 229 | + | return binding.Keys | |||
| 230 | + | } | |||
| 231 | + | case "pane": | |||
| 232 | + | return defaults.Workspace.PaneMode.Actions[action].Keys | |||
| 233 | + | case "tab": | |||
| 234 | + | return defaults.Workspace.TabMode.Actions[action].Keys | |||
| 235 | + | case "resize": | |||
| 236 | + | return defaults.Workspace.ResizeMode.Actions[action].Keys | |||
| 237 | + | case "session": | |||
| 238 | + | return defaults.Session.SessionMode.Actions[action].Keys | |||
| 239 | + | } | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // checkConflicts scans all modes for keybinding conflicts. | |||
| 244 | + | func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction string, newKeys []string) []port.KeybindingConflict { | |||
| 245 | + | var conflicts []port.KeybindingConflict | |||
| 246 | + | ||||
| 247 | + | type binding struct { | |||
| 248 | + | mode string | |||
| 249 | + | action string | |||
| 250 | + | } | |||
| 251 | + | keyMap := make(map[string][]binding) | |||
| 252 | + | ||||
| 253 | + | addBindings := func(mode string, actions map[string]ActionBinding) { | |||
| 254 | + | for action, ab := range actions { | |||
| 255 | + | for _, key := range ab.Keys { | |||
| 256 | + | keyMap[key] = append(keyMap[key], binding{mode: mode, action: action}) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | |||
| 262 | + | addBindings("pane", cfg.Workspace.PaneMode.Actions) | |||
| 263 | + | addBindings("tab", cfg.Workspace.TabMode.Actions) | |||
| 264 | + | addBindings("resize", cfg.Workspace.ResizeMode.Actions) | |||
| 265 | + | addBindings("session", cfg.Session.SessionMode.Actions) | |||
| 266 | + | ||||
| 267 | + | for _, newKey := range newKeys { | |||
| 268 | + | if existing, ok := keyMap[newKey]; ok { | |||
| 269 | + | for _, b := range existing { | |||
| 270 | + | if b.mode == targetMode && b.action == targetAction { | |||
| 271 | + | continue | |||
| 272 | + | } | |||
| 273 | + | if targetMode == modeGlobal || b.mode == modeGlobal || b.mode == targetMode { | |||
| 274 | + | conflicts = append(conflicts, port.KeybindingConflict{ | |||
| 275 | + | ConflictingAction: b.action, | |||
| 276 | + | ConflictingMode: b.mode, | |||
| 277 | + | Key: newKey, | |||
| 278 | + | }) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | return conflicts | |||
| 285 | + | } | |||
M internal/infrastructure/config/loader.go +61 -50
| @@ -17,11 +17,12 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // Manager handles configuration loading, watching, and reloading. | 18 | // Manager handles configuration loading, watching, and reloading. | ||
| 19 | type Manager struct { | 19 | type Manager struct { | ||
| 20 | - | config *Config | 20 | + | config *Config |
| 21 | - | viper *viper.Viper | 21 | + | viper *viper.Viper |
| 22 | - | mu sync.RWMutex | 22 | + | mu sync.RWMutex |
| 23 | - | callbacks []func(*Config) | 23 | + | callbacks []func(*Config) |
| 24 | - | watching bool | 24 | + | watching bool |
| 25 | + | skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config | |||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | // NewManager creates a new configuration manager. | 28 | // NewManager creates a new configuration manager. | ||
| @@ -130,9 +131,26 @@ func (m *Manager) readConfigFile() error { | |||||
| 130 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | 131 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | ||
| 131 | } | 132 | } | ||
| 132 | } | 133 | } | ||
| 134 | + | ||||
| 135 | + | // Transform legacy action bindings before unmarshaling | |||
| 136 | + | m.transformLegacyActionBindings() | |||
| 137 | + | ||||
| 133 | return nil | 138 | return nil | ||
| 134 | } | 139 | } | ||
| 135 | 140 | ||||
| 141 | + | // transformLegacyActionBindings converts old-format action bindings to new format. | |||
| 142 | + | // This is called after reading config but before unmarshaling. | |||
| 143 | + | func (m *Manager) transformLegacyActionBindings() { | |||
| 144 | + | rawConfig := m.viper.AllSettings() | |||
| 145 | + | transformer := NewLegacyConfigTransformer() | |||
| 146 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 147 | + | ||||
| 148 | + | // Apply transformed config back to viper | |||
| 149 | + | for key, value := range rawConfig { | |||
| 150 | + | m.viper.Set(key, value) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 136 | func (m *Manager) unmarshalConfig() (*Config, error) { | 154 | func (m *Manager) unmarshalConfig() (*Config, error) { | ||
| 137 | config := &Config{} | 155 | config := &Config{} | ||
| 138 | if err := m.viper.Unmarshal(config); err != nil { | 156 | if err := m.viper.Unmarshal(config); err != nil { | ||
| @@ -242,7 +260,7 @@ func (m *Manager) Get() *Config { | |||||
| 242 | return &configCopy | 260 | return &configCopy | ||
| 243 | } | 261 | } | ||
| 244 | 262 | ||||
| 245 | - | // Save saves the provided configuration to disk and updates Viper. | 263 | + | // Save saves the provided configuration to disk with ordered sections. |
| 246 | func (m *Manager) Save(cfg *Config) error { | 264 | func (m *Manager) Save(cfg *Config) error { | ||
| 247 | m.mu.Lock() | 265 | m.mu.Lock() | ||
| 248 | defer m.mu.Unlock() | 266 | defer m.mu.Unlock() | ||
| @@ -256,31 +274,32 @@ func (m *Manager) Save(cfg *Config) error { | |||||
| 256 | return fmt.Errorf("configuration validation failed: %w", err) | 274 | return fmt.Errorf("configuration validation failed: %w", err) | ||
| 257 | } | 275 | } | ||
| 258 | 276 | ||||
| 259 | - | // Update Viper with the new values. | 277 | + | // Write config with ordered sections (bypasses Viper's random map ordering) |
| 260 | - | // Since we can't easily update Viper from a struct automatically while preserving | 278 | + | configFile := m.viper.ConfigFileUsed() |
| 261 | - | // all settings, we update the main keys we care about. | 279 | + | if configFile == "" { |
| 262 | - | m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont) | 280 | + | var err error |
| 263 | - | m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont) | 281 | + | configFile, err = GetConfigFile() |
| 264 | - | m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont) | 282 | + | if err != nil { |
| 265 | - | m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize) | 283 | + | return fmt.Errorf("failed to determine config file path: %w", err) |
| 266 | - | m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme) | 284 | + | } |
| 267 | - | m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette) | 285 | + | } |
| 268 | - | m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette) | 286 | + | |
| 269 | - | m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom) | 287 | + | // Set flag BEFORE writing to prevent fsnotify-triggered reload from |
| 270 | - | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | 288 | + | // overwriting our in-memory config with stale viper cache |
| 271 | - | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | 289 | + | m.skipNextReload = true |
| 272 | - | 290 | + | ||
| 273 | - | // Performance profile (requires browser restart to take effect) | 291 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { |
| 274 | - | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | 292 | + | m.skipNextReload = false // Reset on error |
| 275 | - | ||||
| 276 | - | if err := m.viper.WriteConfig(); err != nil { | |||
| 277 | return fmt.Errorf("failed to write config: %w", err) | 293 | return fmt.Errorf("failed to write config: %w", err) | ||
| 278 | } | 294 | } | ||
| 279 | 295 | ||||
| 280 | - | // We don't update m.config here manually because viper.OnConfigChange | 296 | + | // Update in-memory config immediately (don't wait for file watcher) |
| 281 | - | // will trigger reload() if Watch() is active. | 297 | + | // This ensures subsequent Get() calls return the new values |
| 282 | - | // If Watch() is not active, we should call reload() manually. | 298 | + | m.config = cfg |
| 299 | + | ||||
| 300 | + | // Also reload from disk to sync Viper's internal state (if not watching) | |||
| 283 | if !m.watching { | 301 | if !m.watching { | ||
| 302 | + | m.skipNextReload = false // Clear flag since we're doing manual reload | |||
| 284 | if err := m.reload(); err != nil { | 303 | if err := m.reload(); err != nil { | ||
| 285 | return err | 304 | return err | ||
| 286 | } | 305 | } | ||
| @@ -294,7 +313,7 @@ func (m *Manager) GetConfigFile() string { | |||||
| 294 | return m.viper.ConfigFileUsed() | 313 | return m.viper.ConfigFileUsed() | ||
| 295 | } | 314 | } | ||
| 296 | 315 | ||||
| 297 | - | // createDefaultConfig creates a default configuration file. | 316 | + | // createDefaultConfig creates a default configuration file with ordered sections. |
| 298 | func (m *Manager) createDefaultConfig() error { | 317 | func (m *Manager) createDefaultConfig() error { | ||
| 299 | configFile, err := GetConfigFile() | 318 | configFile, err := GetConfigFile() | ||
| 300 | if err != nil { | 319 | if err != nil { | ||
| @@ -306,12 +325,14 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 306 | return err | 325 | return err | ||
| 307 | } | 326 | } | ||
| 308 | 327 | ||||
| 309 | - | // Detect available fonts and override defaults before writing config. | 328 | + | // Start with default config |
| 310 | - | m.detectAndSetFonts() | 329 | + | cfg := DefaultConfig() |
| 330 | + | ||||
| 331 | + | // Detect available fonts and override defaults | |||
| 332 | + | m.detectAndSetFontsOnConfig(cfg) | |||
| 311 | 333 | ||||
| 312 | - | // Ensure TOML format and write config | 334 | + | // Write config with ordered sections |
| 313 | - | m.viper.SetConfigType("toml") | 335 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { |
| 314 | - | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |||
| 315 | return fmt.Errorf("failed to write config file: %w", err) | 336 | return fmt.Errorf("failed to write config file: %w", err) | ||
| 316 | } | 337 | } | ||
| 317 | 338 | ||||
| @@ -320,9 +341,9 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 320 | return nil | 341 | return nil | ||
| 321 | } | 342 | } | ||
| 322 | 343 | ||||
| 323 | - | // detectAndSetFonts detects available system fonts and sets the best available | 344 | + | // detectAndSetFontsOnConfig detects available system fonts and sets the best available |
| 324 | - | // fonts from the fallback chains. This runs only during first-run config creation. | 345 | + | // fonts from the fallback chains directly on the Config struct. |
| 325 | - | func (m *Manager) detectAndSetFonts() { | 346 | + | func (*Manager) detectAndSetFontsOnConfig(cfg *Config) { |
| 326 | // Create context with logger for debugging first-run font detection. | 347 | // Create context with logger for debugging first-run font detection. | ||
| 327 | logger := logging.NewFromEnv() | 348 | logger := logging.NewFromEnv() | ||
| 328 | ctx := logging.WithContext(context.Background(), logger) | 349 | ctx := logging.WithContext(context.Background(), logger) | ||
| @@ -334,13 +355,9 @@ func (m *Manager) detectAndSetFonts() { | |||||
| 334 | return | 355 | return | ||
| 335 | } | 356 | } | ||
| 336 | 357 | ||||
| 337 | - | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | 358 | + | cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) |
| 338 | - | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | 359 | + | cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) |
| 339 | - | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | 360 | + | cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) |
| 340 | - | ||||
| 341 | - | m.viper.Set("appearance.sans_font", sansFont) | |||
| 342 | - | m.viper.Set("appearance.serif_font", serifFont) | |||
| 343 | - | m.viper.Set("appearance.monospace_font", monoFont) | |||
| 344 | } | 361 | } | ||
| 345 | 362 | ||||
| 346 | // setDefaults sets default configuration values in Viper. | 363 | // setDefaults sets default configuration values in Viper. | ||
| @@ -443,13 +460,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 443 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | 460 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | ||
| 444 | m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent) | 461 | m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent) | ||
| 445 | m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions) | 462 | m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions) | ||
| 446 | - | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | 463 | + | m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions) |
| 447 | - | m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab) | |||
| 448 | - | m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab) | |||
| 449 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft) | |||
| 450 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight) | |||
| 451 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp) | |||
| 452 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | |||
| 453 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | 464 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | ||
| 454 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | 465 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | ||
| 455 | m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove) | 466 | m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove) | ||
M internal/infrastructure/config/migrate.go +16 -2
| @@ -371,6 +371,10 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| 372 | } | 372 | } | ||
| 373 | 373 | ||||
| 374 | + | // Transform legacy action bindings (slice -> ActionBinding struct) | |||
| 375 | + | transformer := NewLegacyConfigTransformer() | |||
| 376 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 377 | + | ||||
| 374 | // Build sets of keys to remove and renames to apply | 378 | // Build sets of keys to remove and renames to apply | ||
| 375 | keysToRemove := make(map[string]bool) | 379 | keysToRemove := make(map[string]bool) | ||
| 376 | renames := make(map[string]string) // oldKey -> newKey | 380 | renames := make(map[string]string) // oldKey -> newKey | ||
| @@ -416,8 +420,14 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 416 | return nil, fmt.Errorf("failed to merge config: %w", err) | 420 | return nil, fmt.Errorf("failed to merge config: %w", err) | ||
| 417 | } | 421 | } | ||
| 418 | 422 | ||||
| 419 | - | // Write the merged config back | 423 | + | // Unmarshal to Config struct for ordered writing |
| 420 | - | if err := userViper.WriteConfig(); err != nil { | 424 | + | var cfg Config |
| 425 | + | if err := userViper.Unmarshal(&cfg); err != nil { | |||
| 426 | + | return nil, fmt.Errorf("failed to unmarshal merged config: %w", err) | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // Write with ordered sections | |||
| 430 | + | if err := WriteConfigOrdered(&cfg, configFile); err != nil { | |||
| 421 | return nil, fmt.Errorf("failed to write config file: %w", err) | 431 | return nil, fmt.Errorf("failed to write config file: %w", err) | ||
| 422 | } | 432 | } | ||
| 423 | 433 | ||||
| @@ -635,6 +645,10 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an | |||||
| 635 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | 645 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | ||
| 636 | } | 646 | } | ||
| 637 | 647 | ||||
| 648 | + | // Transform legacy action bindings before flattening | |||
| 649 | + | transformer := NewLegacyConfigTransformer() | |||
| 650 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 651 | + | ||||
| 638 | // Flatten the map to dot-notation keys with values | 652 | // Flatten the map to dot-notation keys with values | ||
| 639 | result := make(map[string]any) | 653 | result := make(map[string]any) | ||
| 640 | m.flattenMapWithValues(rawConfig, "", result) | 654 | m.flattenMapWithValues(rawConfig, "", result) | ||
M internal/infrastructure/config/schema.go +39 -29
| @@ -410,19 +410,25 @@ type WorkspaceConfig struct { | |||||
| 410 | Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"` | 410 | Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"` | ||
| 411 | } | 411 | } | ||
| 412 | 412 | ||||
| 413 | + | // ActionBinding defines a keybinding with optional description. | |||
| 414 | + | type ActionBinding struct { | |||
| 415 | + | Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"` | |||
| 416 | + | Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"` | |||
| 417 | + | } | |||
| 418 | + | ||||
| 413 | // PaneModeConfig defines modal behavior for pane management. | 419 | // PaneModeConfig defines modal behavior for pane management. | ||
| 414 | type PaneModeConfig struct { | 420 | type PaneModeConfig struct { | ||
| 415 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 421 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 416 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 422 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 417 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 423 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 418 | } | 424 | } | ||
| 419 | 425 | ||||
| 420 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 426 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 421 | // This is built from the action→keys structure in the config. | 427 | // This is built from the action→keys structure in the config. | ||
| 422 | func (p *PaneModeConfig) GetKeyBindings() map[string]string { | 428 | func (p *PaneModeConfig) GetKeyBindings() map[string]string { | ||
| 423 | keyToAction := make(map[string]string) | 429 | keyToAction := make(map[string]string) | ||
| 424 | - | for action, keys := range p.Actions { | 430 | + | for action, binding := range p.Actions { |
| 425 | - | for _, key := range keys { | 431 | + | for _, key := range binding.Keys { |
| 426 | keyToAction[key] = action | 432 | keyToAction[key] = action | ||
| 427 | } | 433 | } | ||
| 428 | } | 434 | } | ||
| @@ -431,17 +437,17 @@ func (p *PaneModeConfig) GetKeyBindings() map[string]string { | |||||
| 431 | 437 | ||||
| 432 | // TabModeConfig defines modal behavior for tab management (Zellij-style). | 438 | // TabModeConfig defines modal behavior for tab management (Zellij-style). | ||
| 433 | type TabModeConfig struct { | 439 | type TabModeConfig struct { | ||
| 434 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 440 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 435 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 441 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 436 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 442 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 437 | } | 443 | } | ||
| 438 | 444 | ||||
| 439 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 445 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 440 | // This is built from the action→keys structure in the config. | 446 | // This is built from the action→keys structure in the config. | ||
| 441 | func (t *TabModeConfig) GetKeyBindings() map[string]string { | 447 | func (t *TabModeConfig) GetKeyBindings() map[string]string { | ||
| 442 | keyToAction := make(map[string]string) | 448 | keyToAction := make(map[string]string) | ||
| 443 | - | for action, keys := range t.Actions { | 449 | + | for action, binding := range t.Actions { |
| 444 | - | for _, key := range keys { | 450 | + | for _, key := range binding.Keys { |
| 445 | keyToAction[key] = action | 451 | keyToAction[key] = action | ||
| 446 | } | 452 | } | ||
| 447 | } | 453 | } | ||
| @@ -450,19 +456,19 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||||
| 450 | 456 | ||||
| 451 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | 457 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | ||
| 452 | type ResizeModeConfig struct { | 458 | type ResizeModeConfig struct { | ||
| 453 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 459 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 454 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 460 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 455 | - | StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"` | 461 | + | StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"` |
| 456 | - | MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line | 462 | + | MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line |
| 457 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 463 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 458 | } | 464 | } | ||
| 459 | 465 | ||||
| 460 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 466 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 461 | // This is built from the action→keys structure in the config. | 467 | // This is built from the action→keys structure in the config. | ||
| 462 | func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | 468 | func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | ||
| 463 | keyToAction := make(map[string]string) | 469 | keyToAction := make(map[string]string) | ||
| 464 | - | for action, keys := range r.Actions { | 470 | + | for action, binding := range r.Actions { |
| 465 | - | for _, key := range keys { | 471 | + | for _, key := range binding.Keys { |
| 466 | keyToAction[key] = action | 472 | keyToAction[key] = action | ||
| 467 | } | 473 | } | ||
| 468 | } | 474 | } | ||
| @@ -471,14 +477,18 @@ func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | |||||
| 471 | 477 | ||||
| 472 | // GlobalShortcutsConfig defines global shortcuts (always active, not modal). | 478 | // GlobalShortcutsConfig defines global shortcuts (always active, not modal). | ||
| 473 | type GlobalShortcutsConfig struct { | 479 | type GlobalShortcutsConfig struct { | ||
| 474 | - | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | 480 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 475 | - | NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"` | 481 | + | } |
| 476 | - | PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"` | |||
| 477 | 482 | ||||
| 478 | - | ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line | 483 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. |
| 479 | - | ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line | 484 | + | func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string { |
| 480 | - | ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line | 485 | + | keyToAction := make(map[string]string) |
| 481 | - | ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line | 486 | + | for action, binding := range g.Actions { |
| 487 | + | for _, key := range binding.Keys { | |||
| 488 | + | keyToAction[key] = action | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | return keyToAction | |||
| 482 | } | 492 | } | ||
| 483 | 493 | ||||
| 484 | // PopupBehavior defines how popup windows should be opened | 494 | // PopupBehavior defines how popup windows should be opened | ||
| @@ -574,17 +584,17 @@ type SessionConfig struct { | |||||
| 574 | 584 | ||||
| 575 | // SessionModeConfig defines modal behavior for session management. | 585 | // SessionModeConfig defines modal behavior for session management. | ||
| 576 | type SessionModeConfig struct { | 586 | type SessionModeConfig struct { | ||
| 577 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 587 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 578 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 588 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 579 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 589 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 580 | } | 590 | } | ||
| 581 | 591 | ||||
| 582 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 592 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 583 | // This is built from the action→keys structure in the config. | 593 | // This is built from the action→keys structure in the config. | ||
| 584 | func (s *SessionModeConfig) GetKeyBindings() map[string]string { | 594 | func (s *SessionModeConfig) GetKeyBindings() map[string]string { | ||
| 585 | keyToAction := make(map[string]string) | 595 | keyToAction := make(map[string]string) | ||
| 586 | - | for action, keys := range s.Actions { | 596 | + | for action, binding := range s.Actions { |
| 587 | - | for _, key := range keys { | 597 | + | for _, key := range binding.Keys { |
| 588 | keyToAction[key] = action | 598 | keyToAction[key] = action | ||
| 589 | } | 599 | } | ||
| 590 | } | 600 | } | ||
M internal/infrastructure/config/schema_provider.go +5 -47
| @@ -445,54 +445,12 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 445 | Description: "Key bindings for resize mode actions", | 445 | Description: "Key bindings for resize mode actions", | ||
| 446 | Section: SectionWorkspace, | 446 | Section: SectionWorkspace, | ||
| 447 | }, | 447 | }, | ||
| 448 | - | // Global shortcuts | 448 | + | // Global shortcuts - managed via dedicated keybindings UI |
| 449 | { | 449 | { | ||
| 450 | - | Key: "workspace.shortcuts.close_pane", | 450 | + | Key: "workspace.shortcuts.actions", |
| 451 | - | Type: "string", | 451 | + | Type: "object", |
| 452 | - | Default: defaults.Workspace.Shortcuts.ClosePane, | 452 | + | Default: "(see defaults)", |
| 453 | - | Description: "Global shortcut to close current pane", | 453 | + | Description: "Global keyboard shortcuts (use Keybindings tab to edit)", |
| 454 | - | Section: SectionWorkspace, | |||
| 455 | - | }, | |||
| 456 | - | { | |||
| 457 | - | Key: "workspace.shortcuts.next_tab", | |||
| 458 | - | Type: "string", | |||
| 459 | - | Default: defaults.Workspace.Shortcuts.NextTab, | |||
| 460 | - | Description: "Global shortcut to switch to next tab", | |||
| 461 | - | Section: SectionWorkspace, | |||
| 462 | - | }, | |||
| 463 | - | { | |||
| 464 | - | Key: "workspace.shortcuts.previous_tab", | |||
| 465 | - | Type: "string", | |||
| 466 | - | Default: defaults.Workspace.Shortcuts.PreviousTab, | |||
| 467 | - | Description: "Global shortcut to switch to previous tab", | |||
| 468 | - | Section: SectionWorkspace, | |||
| 469 | - | }, | |||
| 470 | - | { | |||
| 471 | - | Key: "workspace.shortcuts.consume_or_expel_left", | |||
| 472 | - | Type: "string", | |||
| 473 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelLeft, | |||
| 474 | - | Description: "Consume into left sibling stack or expel left (alpha)", | |||
| 475 | - | Section: SectionWorkspace, | |||
| 476 | - | }, | |||
| 477 | - | { | |||
| 478 | - | Key: "workspace.shortcuts.consume_or_expel_right", | |||
| 479 | - | Type: "string", | |||
| 480 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelRight, | |||
| 481 | - | Description: "Consume into right sibling stack or expel right (alpha)", | |||
| 482 | - | Section: SectionWorkspace, | |||
| 483 | - | }, | |||
| 484 | - | { | |||
| 485 | - | Key: "workspace.shortcuts.consume_or_expel_up", | |||
| 486 | - | Type: "string", | |||
| 487 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelUp, | |||
| 488 | - | Description: "Consume into upper sibling stack or expel up (alpha)", | |||
| 489 | - | Section: SectionWorkspace, | |||
| 490 | - | }, | |||
| 491 | - | { | |||
| 492 | - | Key: "workspace.shortcuts.consume_or_expel_down", | |||
| 493 | - | Type: "string", | |||
| 494 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelDown, | |||
| 495 | - | Description: "Consume into lower sibling stack or expel down (alpha)", | |||
| 496 | Section: SectionWorkspace, | 454 | Section: SectionWorkspace, | ||
| 497 | }, | 455 | }, | ||
| 498 | // Popups | 456 | // Popups | ||
A internal/infrastructure/config/transform_legacy.go +109 -0
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | const ( | |||
| 4 | + | sectionWorkspace = "workspace" | |||
| 5 | + | sectionSession = "session" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // LegacyConfigTransformer implements port.ConfigTransformer. | |||
| 9 | + | // It transforms old-format action bindings (slices) to new ActionBinding format. | |||
| 10 | + | type LegacyConfigTransformer struct { | |||
| 11 | + | defaults *Config | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // NewLegacyConfigTransformer creates a new transformer with defaults for description lookup. | |||
| 15 | + | func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||
| 16 | + | return &LegacyConfigTransformer{defaults: DefaultConfig()} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // TransformLegacyActions converts old-format action bindings to new format. | |||
| 20 | + | // This handles migration from: | |||
| 21 | + | // | |||
| 22 | + | // [workspace.pane_mode.actions] | |||
| 23 | + | // "focus-left" = ["h", "arrowleft"] | |||
| 24 | + | // | |||
| 25 | + | // To: | |||
| 26 | + | // | |||
| 27 | + | // [workspace.pane_mode.actions.focus-left] | |||
| 28 | + | // keys = ["h", "arrowleft"] | |||
| 29 | + | // desc = "Focus pane to the left" | |||
| 30 | + | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |||
| 31 | + | actionPaths := [][]string{ | |||
| 32 | + | {"workspace", "pane_mode", "actions"}, | |||
| 33 | + | {"workspace", "tab_mode", "actions"}, | |||
| 34 | + | {"workspace", "resize_mode", "actions"}, | |||
| 35 | + | {"workspace", "shortcuts", "actions"}, | |||
| 36 | + | {"session", "session_mode", "actions"}, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | for _, path := range actionPaths { | |||
| 40 | + | t.transformActionSection(rawConfig, path) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |||
| 45 | + | // Navigate to the parent of the actions section | |||
| 46 | + | current := rawConfig | |||
| 47 | + | for _, key := range path[:len(path)-1] { | |||
| 48 | + | next, ok := current[key].(map[string]any) | |||
| 49 | + | if !ok { | |||
| 50 | + | return // Path doesn't exist | |||
| 51 | + | } | |||
| 52 | + | current = next | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | actionsKey := path[len(path)-1] | |||
| 56 | + | actionsRaw, ok := current[actionsKey] | |||
| 57 | + | if !ok { | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | actions, ok := actionsRaw.(map[string]any) | |||
| 62 | + | if !ok { | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Check each action and transform if needed | |||
| 67 | + | for actionName, actionValue := range actions { | |||
| 68 | + | // If value is a slice (old format), convert to ActionBinding format | |||
| 69 | + | if slice, ok := actionValue.([]any); ok { | |||
| 70 | + | keys := make([]string, 0, len(slice)) | |||
| 71 | + | for _, v := range slice { | |||
| 72 | + | if s, ok := v.(string); ok { | |||
| 73 | + | keys = append(keys, s) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | actions[actionName] = map[string]any{ | |||
| 78 | + | "keys": keys, | |||
| 79 | + | "desc": t.getDefaultDesc(path, actionName), | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string { | |||
| 86 | + | switch { | |||
| 87 | + | case path[0] == sectionWorkspace && path[1] == "pane_mode": | |||
| 88 | + | if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok { | |||
| 89 | + | return b.Desc | |||
| 90 | + | } | |||
| 91 | + | case path[0] == sectionWorkspace && path[1] == "tab_mode": | |||
| 92 | + | if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok { | |||
| 93 | + | return b.Desc | |||
| 94 | + | } | |||
| 95 | + | case path[0] == sectionWorkspace && path[1] == "resize_mode": | |||
| 96 | + | if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok { | |||
| 97 | + | return b.Desc | |||
| 98 | + | } | |||
| 99 | + | case path[0] == sectionWorkspace && path[1] == "shortcuts": | |||
| 100 | + | if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok { | |||
| 101 | + | return b.Desc | |||
| 102 | + | } | |||
| 103 | + | case path[0] == sectionSession && path[1] == "session_mode": | |||
| 104 | + | if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok { | |||
| 105 | + | return b.Desc | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return "" | |||
| 109 | + | } | |||
A internal/infrastructure/config/transform_legacy_test.go +193 -0
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestTransformLegacyActions_SliceFormat(t *testing.T) { | |||
| 11 | + | transformer := NewLegacyConfigTransformer() | |||
| 12 | + | ||||
| 13 | + | rawConfig := map[string]any{ | |||
| 14 | + | "workspace": map[string]any{ | |||
| 15 | + | "pane_mode": map[string]any{ | |||
| 16 | + | "actions": map[string]any{ | |||
| 17 | + | "focus-left": []any{"h", "arrowleft"}, | |||
| 18 | + | }, | |||
| 19 | + | }, | |||
| 20 | + | }, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 24 | + | ||||
| 25 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 26 | + | binding, ok := actions["focus-left"].(map[string]any) | |||
| 27 | + | require.True(t, ok, "should be converted to map") | |||
| 28 | + | ||||
| 29 | + | keys, ok := binding["keys"].([]string) | |||
| 30 | + | require.True(t, ok, "keys should be []string") | |||
| 31 | + | assert.Equal(t, []string{"h", "arrowleft"}, keys) | |||
| 32 | + | assert.NotEmpty(t, binding["desc"], "should have description from defaults") | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestTransformLegacyActions_AllModes(t *testing.T) { | |||
| 36 | + | transformer := NewLegacyConfigTransformer() | |||
| 37 | + | ||||
| 38 | + | rawConfig := map[string]any{ | |||
| 39 | + | "workspace": map[string]any{ | |||
| 40 | + | "pane_mode": map[string]any{ | |||
| 41 | + | "actions": map[string]any{ | |||
| 42 | + | "focus-left": []any{"h"}, | |||
| 43 | + | }, | |||
| 44 | + | }, | |||
| 45 | + | "tab_mode": map[string]any{ | |||
| 46 | + | "actions": map[string]any{ | |||
| 47 | + | "next-tab": []any{"l"}, | |||
| 48 | + | }, | |||
| 49 | + | }, | |||
| 50 | + | "resize_mode": map[string]any{ | |||
| 51 | + | "actions": map[string]any{ | |||
| 52 | + | "resize-increase": []any{"+"}, | |||
| 53 | + | }, | |||
| 54 | + | }, | |||
| 55 | + | "shortcuts": map[string]any{ | |||
| 56 | + | "actions": map[string]any{ | |||
| 57 | + | "close_pane": []any{"ctrl+w"}, | |||
| 58 | + | }, | |||
| 59 | + | }, | |||
| 60 | + | }, | |||
| 61 | + | "session": map[string]any{ | |||
| 62 | + | "session_mode": map[string]any{ | |||
| 63 | + | "actions": map[string]any{ | |||
| 64 | + | "session-manager": []any{"m"}, | |||
| 65 | + | }, | |||
| 66 | + | }, | |||
| 67 | + | }, | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 71 | + | ||||
| 72 | + | // Check pane mode | |||
| 73 | + | paneActions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 74 | + | paneFocus, ok := paneActions["focus-left"].(map[string]any) | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | assert.Equal(t, []string{"h"}, paneFocus["keys"]) | |||
| 77 | + | ||||
| 78 | + | // Check tab mode | |||
| 79 | + | tabActions := rawConfig["workspace"].(map[string]any)["tab_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 80 | + | tabNext, ok := tabActions["next-tab"].(map[string]any) | |||
| 81 | + | require.True(t, ok) | |||
| 82 | + | assert.Equal(t, []string{"l"}, tabNext["keys"]) | |||
| 83 | + | ||||
| 84 | + | // Check resize mode | |||
| 85 | + | resizeActions := rawConfig["workspace"].(map[string]any)["resize_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 86 | + | resizeInc, ok := resizeActions["resize-increase"].(map[string]any) | |||
| 87 | + | require.True(t, ok) | |||
| 88 | + | assert.Equal(t, []string{"+"}, resizeInc["keys"]) | |||
| 89 | + | ||||
| 90 | + | // Check shortcuts | |||
| 91 | + | shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any) | |||
| 92 | + | closePane, ok := shortcutActions["close_pane"].(map[string]any) | |||
| 93 | + | require.True(t, ok) | |||
| 94 | + | assert.Equal(t, []string{"ctrl+w"}, closePane["keys"]) | |||
| 95 | + | ||||
| 96 | + | // Check session mode | |||
| 97 | + | sessionActions := rawConfig["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 98 | + | sessionMgr, ok := sessionActions["session-manager"].(map[string]any) | |||
| 99 | + | require.True(t, ok) | |||
| 100 | + | assert.Equal(t, []string{"m"}, sessionMgr["keys"]) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestTransformLegacyActions_AlreadyNewFormat(t *testing.T) { | |||
| 104 | + | transformer := NewLegacyConfigTransformer() | |||
| 105 | + | ||||
| 106 | + | rawConfig := map[string]any{ | |||
| 107 | + | "workspace": map[string]any{ | |||
| 108 | + | "pane_mode": map[string]any{ | |||
| 109 | + | "actions": map[string]any{ | |||
| 110 | + | "focus-left": map[string]any{ | |||
| 111 | + | "keys": []string{"h"}, | |||
| 112 | + | "desc": "Custom description", | |||
| 113 | + | }, | |||
| 114 | + | }, | |||
| 115 | + | }, | |||
| 116 | + | }, | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 120 | + | ||||
| 121 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 122 | + | binding := actions["focus-left"].(map[string]any) | |||
| 123 | + | ||||
| 124 | + | // Should remain unchanged | |||
| 125 | + | assert.Equal(t, "Custom description", binding["desc"]) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestTransformLegacyActions_MissingSection(t *testing.T) { | |||
| 129 | + | transformer := NewLegacyConfigTransformer() | |||
| 130 | + | ||||
| 131 | + | rawConfig := map[string]any{ | |||
| 132 | + | "appearance": map[string]any{ | |||
| 133 | + | "theme": "dark", | |||
| 134 | + | }, | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Should not panic | |||
| 138 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 139 | + | ||||
| 140 | + | // Config should be unchanged | |||
| 141 | + | assert.Equal(t, "dark", rawConfig["appearance"].(map[string]any)["theme"]) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestTransformLegacyActions_EmptyConfig(t *testing.T) { | |||
| 145 | + | transformer := NewLegacyConfigTransformer() | |||
| 146 | + | ||||
| 147 | + | rawConfig := map[string]any{} | |||
| 148 | + | ||||
| 149 | + | // Should not panic | |||
| 150 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 151 | + | ||||
| 152 | + | assert.Empty(t, rawConfig) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestTransformLegacyActions_PartialPath(t *testing.T) { | |||
| 156 | + | transformer := NewLegacyConfigTransformer() | |||
| 157 | + | ||||
| 158 | + | rawConfig := map[string]any{ | |||
| 159 | + | "workspace": map[string]any{ | |||
| 160 | + | "pane_mode": map[string]any{ | |||
| 161 | + | // actions section missing | |||
| 162 | + | }, | |||
| 163 | + | }, | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Should not panic | |||
| 167 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 168 | + | ||||
| 169 | + | // Verify config is unchanged | |||
| 170 | + | require.NotNil(t, rawConfig["workspace"]) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |||
| 174 | + | transformer := NewLegacyConfigTransformer() | |||
| 175 | + | ||||
| 176 | + | rawConfig := map[string]any{ | |||
| 177 | + | "workspace": map[string]any{ | |||
| 178 | + | "pane_mode": map[string]any{ | |||
| 179 | + | "actions": map[string]any{ | |||
| 180 | + | "unknown-action": []any{"x"}, | |||
| 181 | + | }, | |||
| 182 | + | }, | |||
| 183 | + | }, | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 187 | + | ||||
| 188 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 189 | + | binding, ok := actions["unknown-action"].(map[string]any) | |||
| 190 | + | require.True(t, ok) | |||
| 191 | + | assert.Equal(t, []string{"x"}, binding["keys"]) | |||
| 192 | + | assert.Empty(t, binding["desc"], "unknown action should have empty description") | |||
| 193 | + | } | |||
M internal/infrastructure/config/validation.go +6 -6
| @@ -166,11 +166,11 @@ func validatePaneMode(config *Config) []string { | |||||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | seenKeys := make(map[string]string) | 168 | seenKeys := make(map[string]string) | ||
| 169 | - | for action, keys := range config.Workspace.PaneMode.Actions { | 169 | + | for action, binding := range config.Workspace.PaneMode.Actions { |
| 170 | - | if len(keys) == 0 { | 170 | + | if len(binding.Keys) == 0 { |
| 171 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action)) | 171 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action)) | ||
| 172 | } | 172 | } | ||
| 173 | - | for _, key := range keys { | 173 | + | for _, key := range binding.Keys { |
| 174 | if existingAction, exists := seenKeys[key]; exists { | 174 | if existingAction, exists := seenKeys[key]; exists { | ||
| 175 | validationErrors = append(validationErrors, fmt.Sprintf( | 175 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 176 | "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", | 176 | "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", | ||
| @@ -204,11 +204,11 @@ func validateTabMode(config *Config) []string { | |||||
| 204 | } | 204 | } | ||
| 205 | 205 | ||||
| 206 | tabSeenKeys := make(map[string]string) | 206 | tabSeenKeys := make(map[string]string) | ||
| 207 | - | for action, keys := range config.Workspace.TabMode.Actions { | 207 | + | for action, binding := range config.Workspace.TabMode.Actions { |
| 208 | - | if len(keys) == 0 { | 208 | + | if len(binding.Keys) == 0 { |
| 209 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action)) | 209 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action)) | ||
| 210 | } | 210 | } | ||
| 211 | - | for _, key := range keys { | 211 | + | for _, key := range binding.Keys { |
| 212 | if existingAction, exists := tabSeenKeys[key]; exists { | 212 | if existingAction, exists := tabSeenKeys[key]; exists { | ||
| 213 | validationErrors = append(validationErrors, fmt.Sprintf( | 213 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 214 | "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", | 214 | "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", | ||
M internal/infrastructure/config/watcher.go +38 -14
| @@ -2,8 +2,8 @@ package config | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | - | "os" | |||
| 6 | 5 | ||||
| 6 | + | "github.com/bnema/dumber/internal/logging" | |||
| 7 | "github.com/fsnotify/fsnotify" | 7 | "github.com/fsnotify/fsnotify" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| @@ -17,29 +17,53 @@ func (m *Manager) Watch() error { | |||||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | m.viper.WatchConfig() | 19 | m.viper.WatchConfig() | ||
| 20 | - | m.viper.OnConfigChange(func(_ fsnotify.Event) { | 20 | + | m.viper.OnConfigChange(func(e fsnotify.Event) { |
| 21 | - | // Reload config | 21 | + | log := logging.NewFromEnv() |
| 22 | - | if err := m.reload(); err != nil { | 22 | + | log.Debug().Str("op", e.Op.String()).Str("file", e.Name).Msg("fsnotify config change detected") |
| 23 | - | fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err) | 23 | + | |
| 24 | + | // Acquire write lock before reload (reload modifies m.config) | |||
| 25 | + | m.mu.Lock() | |||
| 26 | + | ||||
| 27 | + | // Skip reload if this was triggered by our own Save() - the in-memory | |||
| 28 | + | // config is already correct and viper may have stale cached data | |||
| 29 | + | if m.skipNextReload { | |||
| 30 | + | log.Debug().Msg("skipping reload (triggered by own Save)") | |||
| 31 | + | m.skipNextReload = false | |||
| 32 | + | // Still need to sync viper's internal state with the file we just wrote | |||
| 33 | + | if err := m.viper.ReadInConfig(); err != nil { | |||
| 34 | + | log.Warn().Err(err).Msg("failed to sync viper config after Save") | |||
| 35 | + | // Continue anyway - in-memory config is correct, just viper internal state is off | |||
| 36 | + | } | |||
| 37 | + | m.notifyCallbacksLocked() | |||
| 24 | return | 38 | return | ||
| 25 | } | 39 | } | ||
| 26 | 40 | ||||
| 27 | - | // Notify callbacks | 41 | + | log.Debug().Msg("reloading config from external change") |
| 28 | - | m.mu.RLock() | 42 | + | if err := m.reload(); err != nil { |
| 29 | - | config := m.config | 43 | + | m.mu.Unlock() |
| 30 | - | callbacks := make([]func(*Config), len(m.callbacks)) | 44 | + | log.Warn().Err(err).Msg("failed to reload config") |
| 31 | - | copy(callbacks, m.callbacks) | 45 | + | return |
| 32 | - | m.mu.RUnlock() | |||
| 33 | - | ||||
| 34 | - | for _, callback := range callbacks { | |||
| 35 | - | callback(config) | |||
| 36 | } | 46 | } | ||
| 47 | + | m.notifyCallbacksLocked() | |||
| 37 | }) | 48 | }) | ||
| 38 | 49 | ||||
| 39 | m.watching = true | 50 | m.watching = true | ||
| 40 | return nil | 51 | return nil | ||
| 41 | } | 52 | } | ||
| 42 | 53 | ||||
| 54 | + | // notifyCallbacksLocked copies callbacks and config, releases lock, then notifies. | |||
| 55 | + | // Must be called with m.mu held for write. Releases the lock before calling callbacks. | |||
| 56 | + | func (m *Manager) notifyCallbacksLocked() { | |||
| 57 | + | config := m.config | |||
| 58 | + | callbacks := make([]func(*Config), len(m.callbacks)) | |||
| 59 | + | copy(callbacks, m.callbacks) | |||
| 60 | + | m.mu.Unlock() | |||
| 61 | + | ||||
| 62 | + | for _, callback := range callbacks { | |||
| 63 | + | callback(config) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 43 | // OnConfigChange registers a callback function to be called when config changes. | 67 | // OnConfigChange registers a callback function to be called when config changes. | ||
| 44 | func (m *Manager) OnConfigChange(callback func(*Config)) { | 68 | func (m *Manager) OnConfigChange(callback func(*Config)) { | ||
| 45 | m.mu.Lock() | 69 | m.mu.Lock() | ||
A internal/infrastructure/config/writer.go +177 -0
| @@ -0,0 +1,177 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "regexp" | |||
| 9 | + | "sort" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | "github.com/pelletier/go-toml/v2" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // WriteConfigOrdered writes the configuration to disk with consistent ordering. | |||
| 17 | + | // Uses atomic write (temp file + rename) to prevent race conditions with file watchers. | |||
| 18 | + | func WriteConfigOrdered(cfg *Config, path string) error { | |||
| 19 | + | if cfg == nil { | |||
| 20 | + | return fmt.Errorf("config is nil") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | content, err := encodeConfigToTOML(cfg) | |||
| 24 | + | if err != nil { | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | return atomicWriteFile(path, content, filePerm) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // encodeConfigToTOML encodes the config struct to sorted TOML content. | |||
| 32 | + | func encodeConfigToTOML(cfg *Config) (string, error) { | |||
| 33 | + | var buf bytes.Buffer | |||
| 34 | + | enc := toml.NewEncoder(&buf) | |||
| 35 | + | enc.SetIndentTables(true) | |||
| 36 | + | ||||
| 37 | + | if err := enc.Encode(cfg); err != nil { | |||
| 38 | + | return "", fmt.Errorf("failed to encode config: %w", err) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | return sortTOMLSections(buf.String()), nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // atomicWriteFile writes content to a file atomically using temp file + rename. | |||
| 45 | + | // This ensures file watchers only see complete content, not partial writes. | |||
| 46 | + | func atomicWriteFile(path, content string, perm os.FileMode) error { | |||
| 47 | + | dir := filepath.Dir(path) | |||
| 48 | + | tmpFile, err := os.CreateTemp(dir, ".config-*.tmp") | |||
| 49 | + | if err != nil { | |||
| 50 | + | return fmt.Errorf("failed to create temp file: %w", err) | |||
| 51 | + | } | |||
| 52 | + | tmpPath := tmpFile.Name() | |||
| 53 | + | ||||
| 54 | + | // Cleanup on error | |||
| 55 | + | success := false | |||
| 56 | + | defer func() { | |||
| 57 | + | if !success { | |||
| 58 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |||
| 59 | + | log := logging.NewFromEnv() | |||
| 60 | + | log.Warn().Err(removeErr).Str("path", tmpPath).Msg("failed to cleanup temp config file") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | }() | |||
| 64 | + | ||||
| 65 | + | if err := writeAndSync(tmpFile, content); err != nil { | |||
| 66 | + | return err | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | if err := os.Chmod(tmpPath, perm); err != nil { | |||
| 70 | + | return fmt.Errorf("failed to set file permissions: %w", err) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | if err := os.Rename(tmpPath, path); err != nil { | |||
| 74 | + | return fmt.Errorf("failed to rename temp file: %w", err) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | success = true | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // writeAndSync writes content to file and syncs to disk. | |||
| 82 | + | func writeAndSync(f *os.File, content string) (err error) { | |||
| 83 | + | // Ensure file is closed, capturing close error if no prior error | |||
| 84 | + | defer func() { | |||
| 85 | + | closeErr := f.Close() | |||
| 86 | + | if err == nil && closeErr != nil { | |||
| 87 | + | err = fmt.Errorf("failed to close file: %w", closeErr) | |||
| 88 | + | } | |||
| 89 | + | }() | |||
| 90 | + | ||||
| 91 | + | if _, err = f.WriteString(content); err != nil { | |||
| 92 | + | return fmt.Errorf("failed to write file: %w", err) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | if err = f.Sync(); err != nil { | |||
| 96 | + | return fmt.Errorf("failed to sync file: %w", err) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // tomlSection represents a parsed TOML section with its header and content lines. | |||
| 103 | + | type tomlSection struct { | |||
| 104 | + | header string // Section name without brackets (e.g., "workspace.shortcuts") | |||
| 105 | + | lines []string // All lines belonging to this section, including the header | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // sortTOMLSections sorts TOML content so sections appear in alphabetical order. | |||
| 109 | + | func sortTOMLSections(content string) string { | |||
| 110 | + | lines := strings.Split(content, "\n") | |||
| 111 | + | preamble, sections := parseTOMLSections(lines) | |||
| 112 | + | ||||
| 113 | + | sort.Slice(sections, func(i, j int) bool { | |||
| 114 | + | return sections[i].header < sections[j].header | |||
| 115 | + | }) | |||
| 116 | + | ||||
| 117 | + | return buildTOMLOutput(preamble, sections) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // parseTOMLSections parses TOML lines into preamble (top-level keys) and sections. | |||
| 121 | + | func parseTOMLSections(lines []string) (preamble []string, sections []tomlSection) { | |||
| 122 | + | sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`) | |||
| 123 | + | var current *tomlSection | |||
| 124 | + | ||||
| 125 | + | for _, line := range lines { | |||
| 126 | + | if match := sectionRegex.FindStringSubmatch(line); match != nil { | |||
| 127 | + | if current != nil { | |||
| 128 | + | sections = append(sections, *current) | |||
| 129 | + | } | |||
| 130 | + | current = &tomlSection{ | |||
| 131 | + | header: match[2], | |||
| 132 | + | lines: []string{line}, | |||
| 133 | + | } | |||
| 134 | + | } else if current != nil { | |||
| 135 | + | current.lines = append(current.lines, line) | |||
| 136 | + | } else { | |||
| 137 | + | preamble = append(preamble, line) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | if current != nil { | |||
| 142 | + | sections = append(sections, *current) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | return preamble, sections | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // buildTOMLOutput reconstructs TOML content from preamble and sorted sections. | |||
| 149 | + | func buildTOMLOutput(preamble []string, sections []tomlSection) string { | |||
| 150 | + | var result strings.Builder | |||
| 151 | + | ||||
| 152 | + | for _, line := range preamble { | |||
| 153 | + | result.WriteString(line) | |||
| 154 | + | result.WriteString("\n") | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | for i, sec := range sections { | |||
| 158 | + | if i > 0 || len(preamble) > 0 { | |||
| 159 | + | content := result.String() | |||
| 160 | + | if !strings.HasSuffix(content, "\n\n") && content != "" { | |||
| 161 | + | result.WriteString("\n") | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | for _, line := range sec.lines { | |||
| 166 | + | result.WriteString(line) | |||
| 167 | + | result.WriteString("\n") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | output := strings.TrimRight(result.String(), "\n") | |||
| 172 | + | if output != "" { | |||
| 173 | + | output += "\n" | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | return output | |||
| 177 | + | } | |||
A internal/infrastructure/config/writer_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestWriteConfigOrdered(t *testing.T) { | |||
| 11 | + | // Create a temp directory for the test | |||
| 12 | + | tmpDir := t.TempDir() | |||
| 13 | + | configPath := filepath.Join(tmpDir, "config.toml") | |||
| 14 | + | ||||
| 15 | + | // Get default config | |||
| 16 | + | cfg := DefaultConfig() | |||
| 17 | + | ||||
| 18 | + | // Write it | |||
| 19 | + | err := WriteConfigOrdered(cfg, configPath) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("WriteConfigOrdered failed: %v", err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // Read back | |||
| 25 | + | content, err := os.ReadFile(configPath) | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatalf("Failed to read written config: %v", err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Verify sections are in alphabetical order | |||
| 31 | + | lines := strings.Split(string(content), "\n") | |||
| 32 | + | var sections []string | |||
| 33 | + | for _, line := range lines { | |||
| 34 | + | if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { | |||
| 35 | + | sections = append(sections, line) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Check that sections are sorted | |||
| 40 | + | for i := 1; i < len(sections); i++ { | |||
| 41 | + | if sections[i-1] > sections[i] { | |||
| 42 | + | t.Errorf("Sections not sorted: %s > %s", sections[i-1], sections[i]) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | t.Logf("Found %d sections, all sorted", len(sections)) | |||
| 47 | + | ||||
| 48 | + | // Print first 50 lines for debugging | |||
| 49 | + | t.Logf("First 50 lines of output:\n%s", strings.Join(lines[:min(50, len(lines))], "\n")) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestSortTOMLSections(t *testing.T) { | |||
| 53 | + | input := `default_search_engine = 'https://example.com' | |||
| 54 | + | ||||
| 55 | + | [workspace] | |||
| 56 | + | new_pane_url = 'about:blank' | |||
| 57 | + | ||||
| 58 | + | [appearance] | |||
| 59 | + | color_scheme = 'default' | |||
| 60 | + | ||||
| 61 | + | [workspace.pane_mode] | |||
| 62 | + | timeout_ms = 3000 | |||
| 63 | + | ||||
| 64 | + | [appearance.dark_palette] | |||
| 65 | + | background = '#000' | |||
| 66 | + | ` | |||
| 67 | + | ||||
| 68 | + | result := sortTOMLSections(input) | |||
| 69 | + | ||||
| 70 | + | // Extract section order | |||
| 71 | + | lines := strings.Split(result, "\n") | |||
| 72 | + | var sections []string | |||
| 73 | + | for _, line := range lines { | |||
| 74 | + | if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { | |||
| 75 | + | sections = append(sections, line) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Expected order: appearance, appearance.dark_palette, workspace, workspace.pane_mode | |||
| 80 | + | expected := []string{ | |||
| 81 | + | "[appearance]", | |||
| 82 | + | "[appearance.dark_palette]", | |||
| 83 | + | "[workspace]", | |||
| 84 | + | "[workspace.pane_mode]", | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | if len(sections) != len(expected) { | |||
| 88 | + | t.Fatalf("Expected %d sections, got %d: %v", len(expected), len(sections), sections) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | for i, exp := range expected { | |||
| 92 | + | if sections[i] != exp { | |||
| 93 | + | t.Errorf("Section %d: expected %s, got %s", i, exp, sections[i]) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | t.Logf("Sorted output:\n%s", result) | |||
| 98 | + | } | |||
A internal/infrastructure/webkit/handlers/keybindings.go +144 -0
| @@ -0,0 +1,144 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // KeybindingsHandler handles keybinding-related messages. | |||
| 15 | + | type KeybindingsHandler struct { | |||
| 16 | + | getUC *usecase.GetKeybindingsUseCase | |||
| 17 | + | setUC *usecase.SetKeybindingUseCase | |||
| 18 | + | resetUC *usecase.ResetKeybindingUseCase | |||
| 19 | + | resetAllUC *usecase.ResetAllKeybindingsUseCase | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // NewKeybindingsHandler creates a new KeybindingsHandler. | |||
| 23 | + | func NewKeybindingsHandler( | |||
| 24 | + | getUC *usecase.GetKeybindingsUseCase, | |||
| 25 | + | setUC *usecase.SetKeybindingUseCase, | |||
| 26 | + | resetUC *usecase.ResetKeybindingUseCase, | |||
| 27 | + | resetAllUC *usecase.ResetAllKeybindingsUseCase, | |||
| 28 | + | ) *KeybindingsHandler { | |||
| 29 | + | return &KeybindingsHandler{ | |||
| 30 | + | getUC: getUC, | |||
| 31 | + | setUC: setUC, | |||
| 32 | + | resetUC: resetUC, | |||
| 33 | + | resetAllUC: resetAllUC, | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // HandleGetKeybindings returns all keybindings grouped by mode. | |||
| 38 | + | func (h *KeybindingsHandler) HandleGetKeybindings(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) { | |||
| 39 | + | return h.getUC.Execute(ctx) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // HandleSetKeybinding updates a single keybinding. | |||
| 43 | + | func (h *KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 44 | + | log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger() | |||
| 45 | + | log.Debug().RawJSON("payload", payload).Msg("HandleSetKeybinding called") | |||
| 46 | + | ||||
| 47 | + | var req port.SetKeybindingRequest | |||
| 48 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 49 | + | log.Error().Err(err).RawJSON("payload", payload).Msg("failed to unmarshal request") | |||
| 50 | + | return nil, fmt.Errorf("invalid request: %w", err) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | resp, err := h.setUC.Execute(ctx, req) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return nil, err | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | return map[string]any{ | |||
| 59 | + | "status": "success", | |||
| 60 | + | "conflicts": resp.Conflicts, | |||
| 61 | + | }, nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // HandleResetKeybinding resets a keybinding to default. | |||
| 65 | + | func (h *KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 66 | + | log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger() | |||
| 67 | + | ||||
| 68 | + | var req port.ResetKeybindingRequest | |||
| 69 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 70 | + | return nil, fmt.Errorf("invalid request: %w", err) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | log.Info().Str("mode", req.Mode).Str("action", req.Action).Msg("resetting keybinding to default") | |||
| 74 | + | ||||
| 75 | + | if err := h.resetUC.Execute(ctx, req); err != nil { | |||
| 76 | + | return nil, err | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | return map[string]any{"status": "success"}, nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // HandleResetAllKeybindings resets all keybindings to defaults. | |||
| 83 | + | func (h *KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) { | |||
| 84 | + | log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger() | |||
| 85 | + | log.Info().Msg("resetting all keybindings to defaults") | |||
| 86 | + | ||||
| 87 | + | if err := h.resetAllUC.Execute(ctx); err != nil { | |||
| 88 | + | return nil, err | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | return map[string]any{"status": "success"}, nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // RegisterKeybindingsHandlers registers keybindings handlers with the router. | |||
| 95 | + | func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRouter, handler *KeybindingsHandler) error { | |||
| 96 | + | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | |||
| 97 | + | ||||
| 98 | + | // Get all keybindings | |||
| 99 | + | if err := router.RegisterHandlerWithCallbacks( | |||
| 100 | + | "get_keybindings", | |||
| 101 | + | "__dumber_keybindings_loaded", | |||
| 102 | + | "__dumber_keybindings_error", | |||
| 103 | + | "", | |||
| 104 | + | webkit.MessageHandlerFunc(handler.HandleGetKeybindings), | |||
| 105 | + | ); err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Set a single keybinding | |||
| 110 | + | if err := router.RegisterHandlerWithCallbacks( | |||
| 111 | + | "set_keybinding", | |||
| 112 | + | "__dumber_keybinding_set", | |||
| 113 | + | "__dumber_keybinding_set_error", | |||
| 114 | + | "", | |||
| 115 | + | webkit.MessageHandlerFunc(handler.HandleSetKeybinding), | |||
| 116 | + | ); err != nil { | |||
| 117 | + | return err | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Reset a single keybinding | |||
| 121 | + | if err := router.RegisterHandlerWithCallbacks( | |||
| 122 | + | "reset_keybinding", | |||
| 123 | + | "__dumber_keybinding_reset", | |||
| 124 | + | "__dumber_keybinding_reset_error", | |||
| 125 | + | "", | |||
| 126 | + | webkit.MessageHandlerFunc(handler.HandleResetKeybinding), | |||
| 127 | + | ); err != nil { | |||
| 128 | + | return err | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Reset all keybindings | |||
| 132 | + | if err := router.RegisterHandlerWithCallbacks( | |||
| 133 | + | "reset_all_keybindings", | |||
| 134 | + | "__dumber_keybindings_reset_all", | |||
| 135 | + | "__dumber_keybindings_reset_all_error", | |||
| 136 | + | "", | |||
| 137 | + | webkit.MessageHandlerFunc(handler.HandleResetAllKeybindings), | |||
| 138 | + | ); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | log.Info().Msg("registered keybindings handlers") | |||
| 143 | + | return nil | |||
| 144 | + | } | |||
M internal/infrastructure/webkit/handlers/register.go +27 -0
| @@ -2,6 +2,7 @@ package handlers | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 8 | "github.com/bnema/dumber/internal/application/usecase" | ||
| @@ -36,6 +37,15 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 36 | return err | 37 | return err | ||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| 40 | + | // Keybindings handlers (always available) | |||
| 41 | + | keybindingsHandler, err := createKeybindingsHandler() | |||
| 42 | + | if err != nil { | |||
| 43 | + | return fmt.Errorf("failed to create keybindings handler: %w", err) | |||
| 44 | + | } | |||
| 45 | + | if err := RegisterKeybindingsHandlers(ctx, router, keybindingsHandler); err != nil { | |||
| 46 | + | return err | |||
| 47 | + | } | |||
| 48 | + | ||||
| 39 | // Clipboard handlers (for auto-copy on selection feature) | 49 | // Clipboard handlers (for auto-copy on selection feature) | ||
| 40 | if cfg.Clipboard != nil && cfg.ConfigGetter != nil { | 50 | if cfg.Clipboard != nil && cfg.ConfigGetter != nil { | ||
| 41 | if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil { | 51 | if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil { | ||
| @@ -50,3 +60,20 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 50 | 60 | ||||
| 51 | return nil | 61 | return nil | ||
| 52 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | // createKeybindingsHandler wires the gateway and use cases for keybindings. | |||
| 65 | + | func createKeybindingsHandler() (*KeybindingsHandler, error) { | |||
| 66 | + | mgr := config.GetManager() | |||
| 67 | + | if mgr == nil { | |||
| 68 | + | return nil, fmt.Errorf("config manager not initialized") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | gateway := config.NewKeybindingsGateway(mgr) | |||
| 72 | + | ||||
| 73 | + | return NewKeybindingsHandler( | |||
| 74 | + | usecase.NewGetKeybindingsUseCase(gateway), | |||
| 75 | + | usecase.NewSetKeybindingUseCase(gateway, gateway), | |||
| 76 | + | usecase.NewResetKeybindingUseCase(gateway), | |||
| 77 | + | usecase.NewResetAllKeybindingsUseCase(gateway), | |||
| 78 | + | ), nil | |||
| 79 | + | } | |||
M internal/infrastructure/webkit/messaging.go +19 -3
| @@ -174,6 +174,8 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||||
| 174 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | 174 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | ||
| 175 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | 175 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | ||
| 176 | 176 | ||||
| 177 | + | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | |||
| 178 | + | ||||
| 177 | if valuePtr == 0 { | 179 | if valuePtr == 0 { | ||
| 178 | log.Warn().Msg("received script message with nil value pointer") | 180 | log.Warn().Msg("received script message with nil value pointer") | ||
| 179 | return | 181 | return | ||
| @@ -186,8 +188,14 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 186 | } | 188 | } | ||
| 187 | 189 | ||||
| 188 | rawJSON := jscValue.ToJson(0) | 190 | rawJSON := jscValue.ToJson(0) | ||
| 191 | + | log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result") | |||
| 189 | if rawJSON == "" { | 192 | if rawJSON == "" { | ||
| 190 | - | log.Warn().Msg("script message JSON is empty") | 193 | + | // This typically happens when the JS side sends non-serializable values |
| 194 | + | // like Svelte 5 Proxy objects or functions. The frontend should use | |||
| 195 | + | // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects. | |||
| 196 | + | log.Error(). | |||
| 197 | + | Msg("script message JSON serialization failed (ToJson returned empty) - " + | |||
| 198 | + | "this usually means JS sent non-serializable values like Proxy objects") | |||
| 191 | return | 199 | return | ||
| 192 | } | 200 | } | ||
| 193 | 201 | ||||
| @@ -213,7 +221,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 213 | 221 | ||||
| 214 | entry, ok := r.getHandler(msg.Type) | 222 | entry, ok := r.getHandler(msg.Type) | ||
| 215 | if !ok || entry.handler == nil { | 223 | if !ok || entry.handler == nil { | ||
| 216 | - | log.Warn().Str("type", msg.Type).Msg("no handler registered for message type") | 224 | + | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type") |
| 225 | + | // Debug: list all registered handlers | |||
| 226 | + | r.mu.RLock() | |||
| 227 | + | for handlerType := range r.handlers { | |||
| 228 | + | log.Debug().Str("registered_type", handlerType).Msg("available handler") | |||
| 229 | + | } | |||
| 230 | + | r.mu.RUnlock() | |||
| 217 | return | 231 | return | ||
| 218 | } | 232 | } | ||
| 219 | 233 | ||||
| @@ -221,9 +235,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 221 | Str("type", msg.Type). | 235 | Str("type", msg.Type). | ||
| 222 | Uint64("webview_id", msg.WebViewID). | 236 | Uint64("webview_id", msg.WebViewID). | ||
| 223 | Int("payload_len", len(msg.Payload)). | 237 | Int("payload_len", len(msg.Payload)). | ||
| 224 | - | Msg("received script message") | 238 | + | Str("callback", entry.callback). |
| 239 | + | Msg("received script message, dispatching to handler") | |||
| 225 | 240 | ||||
| 226 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | 241 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | ||
| 242 | + | log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") | |||
| 227 | if err != nil { | 243 | if err != nil { | ||
| 228 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | 244 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | ||
| 229 | if entry.errorCallback != "" { | 245 | if entry.errorCallback != "" { | ||
M internal/ui/app.go +5 -1
| @@ -1946,11 +1946,15 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||||
| 1946 | return | 1946 | return | ||
| 1947 | } | 1947 | } | ||
| 1948 | 1948 | ||||
| 1949 | - | // Only appearance is hot-reloaded for now. | 1949 | + | // Hot-reload appearance and keybindings on config change. |
| 1950 | a.configManager.OnConfigChange(func(newCfg *config.Config) { | 1950 | a.configManager.OnConfigChange(func(newCfg *config.Config) { | ||
| 1951 | cfgCopy := newCfg | 1951 | cfgCopy := newCfg | ||
| 1952 | cb := glib.SourceFunc(func(_ uintptr) bool { | 1952 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 1953 | a.applyAppearanceConfig(ctx, cfgCopy) | 1953 | a.applyAppearanceConfig(ctx, cfgCopy) | ||
| 1954 | + | // Reload keyboard shortcuts | |||
| 1955 | + | if a.keyboardHandler != nil { | |||
| 1956 | + | a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy) | |||
| 1957 | + | } | |||
| 1954 | return false | 1958 | return false | ||
| 1955 | }) | 1959 | }) | ||
| 1956 | glib.IdleAdd(&cb, 0) | 1960 | glib.IdleAdd(&cb, 0) | ||
M internal/ui/input/global_shortcuts.go +20 -11
| @@ -94,20 +94,29 @@ func NewGlobalShortcutHandler( | |||||
| 94 | Msg("registered global shortcut") | 94 | Msg("registered global shortcut") | ||
| 95 | 95 | ||||
| 96 | if cfg != nil { | 96 | if cfg != nil { | ||
| 97 | - | registerFromConfig := func(key string, action Action) { | 97 | + | // Map action names to action constants for global shortcuts |
| 98 | - | binding, ok := ParseKeyString(key) | 98 | + | actionMap := map[string]Action{ |
| 99 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 100 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 101 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 102 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions { | |||
| 106 | + | action, ok := actionMap[actionName] | |||
| 99 | if !ok { | 107 | if !ok { | ||
| 100 | - | log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut") | 108 | + | continue |
| 101 | - | return | 109 | + | } |
| 110 | + | for _, keyStr := range actionBinding.Keys { | |||
| 111 | + | binding, ok := ParseKeyString(keyStr) | |||
| 112 | + | if !ok { | |||
| 113 | + | log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut") | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 117 | + | log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut") | |||
| 102 | } | 118 | } | ||
| 103 | - | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 104 | - | log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut") | |||
| 105 | } | 119 | } | ||
| 106 | - | ||||
| 107 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelLeft, ActionConsumeOrExpelLeft) | |||
| 108 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelRight, ActionConsumeOrExpelRight) | |||
| 109 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelUp, ActionConsumeOrExpelUp) | |||
| 110 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelDown, ActionConsumeOrExpelDown) | |||
| 111 | } | 120 | } | ||
| 112 | 121 | ||||
| 113 | // Attach to window | 122 | // Attach to window | ||
M internal/ui/input/keyboard.go +13 -0
| @@ -79,6 +79,19 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { | |||||
| 79 | h.onAction = fn | 79 | h.onAction = fn | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | // ReloadShortcuts rebuilds the shortcut set from the new config. | |||
| 83 | + | // This enables hot-reloading of keybindings without restarting. | |||
| 84 | + | func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) { | |||
| 85 | + | h.mu.Lock() | |||
| 86 | + | defer h.mu.Unlock() | |||
| 87 | + | ||||
| 88 | + | log := logging.FromContext(ctx) | |||
| 89 | + | log.Debug().Msg("reloading shortcuts from config") | |||
| 90 | + | ||||
| 91 | + | h.shortcuts = NewShortcutSet(ctx, cfg) | |||
| 92 | + | h.cfg = cfg | |||
| 93 | + | } | |||
| 94 | + | ||||
| 82 | // SetOnModeChange sets the callback for mode changes (for UI updates). | 95 | // SetOnModeChange sets the callback for mode changes (for UI updates). | ||
| 83 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | 96 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | ||
| 84 | h.modal.SetOnModeChange(fn) | 97 | h.modal.SetOnModeChange(fn) | ||
M internal/ui/input/shortcuts.go +19 -19
| @@ -325,27 +325,27 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |||||
| 325 | // This follows Zellij-style modal keyboard interface. | 325 | // This follows Zellij-style modal keyboard interface. | ||
| 326 | // | 326 | // | ||
| 327 | // However, these standard browser shortcuts ARE global: | 327 | // However, these standard browser shortcuts ARE global: | ||
| 328 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ClosePane); ok { | 328 | + | // Map action names to action constants |
| 329 | - | s.Global[binding] = ActionClosePane // Ctrl+W closes active pane | 329 | + | actionMap := map[string]Action{ |
| 330 | - | } | 330 | + | "close_pane": ActionClosePane, |
| 331 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.NextTab); ok { | 331 | + | "next_tab": ActionNextTab, |
| 332 | - | s.Global[binding] = ActionNextTab // Ctrl+Tab | 332 | + | "previous_tab": ActionPreviousTab, |
| 333 | - | } | 333 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, |
| 334 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | 334 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, |
| 335 | - | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | 335 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, |
| 336 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 336 | } | 337 | } | ||
| 337 | 338 | ||||
| 338 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelLeft); ok { | 339 | + | for actionName, actionBinding := range cfg.Shortcuts.Actions { |
| 339 | - | s.Global[binding] = ActionConsumeOrExpelLeft | 340 | + | action, ok := actionMap[actionName] |
| 340 | - | } | 341 | + | if !ok { |
| 341 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok { | 342 | + | continue |
| 342 | - | s.Global[binding] = ActionConsumeOrExpelRight | 343 | + | } |
| 343 | - | } | 344 | + | for _, keyStr := range actionBinding.Keys { |
| 344 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok { | 345 | + | if binding, ok := ParseKeyString(keyStr); ok { |
| 345 | - | s.Global[binding] = ActionConsumeOrExpelUp | 346 | + | s.Global[binding] = action |
| 346 | - | } | 347 | + | } |
| 347 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok { | 348 | + | } |
| 348 | - | s.Global[binding] = ActionConsumeOrExpelDown | |||
| 349 | } | 349 | } | ||
| 350 | } | 350 | } | ||
| 351 | 351 | ||||
M webui/eslint.config.js +21 -0
| @@ -1,9 +1,30 @@ | |||||
| 1 | import js from "@eslint/js"; | 1 | import js from "@eslint/js"; | ||
| 2 | + | import svelte from "eslint-plugin-svelte"; | |||
| 3 | + | import globals from "globals"; | |||
| 2 | import tseslint from "typescript-eslint"; | 4 | import tseslint from "typescript-eslint"; | ||
| 5 | + | import svelteConfig from "./svelte.config.js"; | |||
| 3 | 6 | ||||
| 4 | export default tseslint.config( | 7 | export default tseslint.config( | ||
| 5 | js.configs.recommended, | 8 | js.configs.recommended, | ||
| 6 | ...tseslint.configs.recommended, | 9 | ...tseslint.configs.recommended, | ||
| 10 | + | ...svelte.configs.recommended, | |||
| 11 | + | { | |||
| 12 | + | languageOptions: { | |||
| 13 | + | globals: { | |||
| 14 | + | ...globals.browser, | |||
| 15 | + | }, | |||
| 16 | + | }, | |||
| 17 | + | }, | |||
| 18 | + | { | |||
| 19 | + | files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], | |||
| 20 | + | languageOptions: { | |||
| 21 | + | parserOptions: { | |||
| 22 | + | extraFileExtensions: [".svelte"], | |||
| 23 | + | parser: tseslint.parser, | |||
| 24 | + | svelteConfig, | |||
| 25 | + | }, | |||
| 26 | + | }, | |||
| 27 | + | }, | |||
| 7 | { | 28 | { | ||
| 8 | files: ["**/*.{js,mjs,cjs,ts}"], | 29 | files: ["**/*.{js,mjs,cjs,ts}"], | ||
| 9 | languageOptions: { | 30 | languageOptions: { | ||
M webui/package-lock.json +308 -7 generated file: collapsed
M webui/package.json +2 -0
| @@ -27,6 +27,8 @@ | |||||
| 27 | "@typescript-eslint/parser": "^7.0.0", | 27 | "@typescript-eslint/parser": "^7.0.0", | ||
| 28 | "autoprefixer": "^10.4.20", | 28 | "autoprefixer": "^10.4.20", | ||
| 29 | "eslint": "^8.57.0", | 29 | "eslint": "^8.57.0", | ||
| 30 | + | "eslint-plugin-svelte": "^3.14.0", | |||
| 31 | + | "globals": "^17.1.0", | |||
| 30 | "postcss": "^8.4.47", | 32 | "postcss": "^8.4.47", | ||
| 31 | "svelte": "^5.0.0", | 33 | "svelte": "^5.0.0", | ||
| 32 | "svelte-awesome-color-picker": "^4.1.0", | 34 | "svelte-awesome-color-picker": "^4.1.0", | ||
A webui/src/lib/bridge.ts +128 -0
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * WebKit bridge utilities for communicating with the Go backend. | |||
| 3 | + | * | |||
| 4 | + | * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived). | |||
| 5 | + | * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it | |||
| 6 | + | * fails silently, dropping the message entirely. This module provides safe | |||
| 7 | + | * serialization that converts Proxies to plain objects before sending. | |||
| 8 | + | */ | |||
| 9 | + | ||||
| 10 | + | export interface BridgeMessage { | |||
| 11 | + | type: string; | |||
| 12 | + | webviewId: number; | |||
| 13 | + | payload: Record<string, unknown>; | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | interface WebKitBridge { | |||
| 17 | + | postMessage: (msg: unknown) => void; | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | /** | |||
| 21 | + | * Get the WebKit message handler bridge, if available. | |||
| 22 | + | */ | |||
| 23 | + | export function getWebKitBridge(): WebKitBridge | null { | |||
| 24 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 25 | + | const bridge = (window as any).webkit?.messageHandlers?.dumber; | |||
| 26 | + | return bridge && typeof bridge.postMessage === "function" ? bridge : null; | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | /** | |||
| 30 | + | * Get the current webview ID assigned by the Go backend. | |||
| 31 | + | */ | |||
| 32 | + | export function getWebViewId(): number { | |||
| 33 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 34 | + | return (window as any).__dumber_webview_id || 0; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | /** | |||
| 38 | + | * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects. | |||
| 39 | + | * Uses JSON serialization which strips Proxy wrappers. | |||
| 40 | + | * | |||
| 41 | + | * @throws Error if the object contains non-serializable values (functions, circular refs, etc.) | |||
| 42 | + | */ | |||
| 43 | + | export function toPlainObject<T>(obj: T): T { | |||
| 44 | + | try { | |||
| 45 | + | return JSON.parse(JSON.stringify(obj)); | |||
| 46 | + | } catch (err) { | |||
| 47 | + | const message = err instanceof Error ? err.message : String(err); | |||
| 48 | + | throw new Error(`Failed to serialize object for bridge: ${message}`); | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | /** | |||
| 53 | + | * Post a message to the Go backend via WebKit bridge. | |||
| 54 | + | * | |||
| 55 | + | * Automatically: | |||
| 56 | + | * - Converts Svelte 5 Proxy objects to plain objects | |||
| 57 | + | * - Adds webviewId if not provided | |||
| 58 | + | * - Logs errors if serialization fails | |||
| 59 | + | * | |||
| 60 | + | * @throws Error if bridge is not available or serialization fails | |||
| 61 | + | */ | |||
| 62 | + | export function postMessage(msg: BridgeMessage): void { | |||
| 63 | + | const bridge = getWebKitBridge(); | |||
| 64 | + | if (!bridge) { | |||
| 65 | + | throw new Error("WebKit bridge not available"); | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Ensure webviewId is set | |||
| 69 | + | if (!msg.webviewId) { | |||
| 70 | + | msg.webviewId = getWebViewId(); | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Convert to plain object to strip Svelte 5 Proxies | |||
| 74 | + | // This is critical - WebKit silently drops messages with Proxy objects | |||
| 75 | + | const plainMsg = toPlainObject(msg); | |||
| 76 | + | bridge.postMessage(plainMsg); | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | /** | |||
| 80 | + | * Post a message and set up success/error callbacks with automatic cleanup. | |||
| 81 | + | * | |||
| 82 | + | * @param type - Message type (e.g., "get_keybindings", "set_keybinding") | |||
| 83 | + | * @param payload - Message payload (will be serialized to strip Proxies) | |||
| 84 | + | * @param successCallback - Window function name to call on success | |||
| 85 | + | * @param errorCallback - Window function name to call on error | |||
| 86 | + | * @param onSuccess - Handler for success response | |||
| 87 | + | * @param onError - Handler for error response | |||
| 88 | + | */ | |||
| 89 | + | export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>( | |||
| 90 | + | type: string, | |||
| 91 | + | payload: TPayload, | |||
| 92 | + | successCallback: string, | |||
| 93 | + | errorCallback: string, | |||
| 94 | + | onSuccess: (response: TResponse) => void, | |||
| 95 | + | onError: (error: string) => void, | |||
| 96 | + | ): void { | |||
| 97 | + | // Cleanup function to remove callbacks from window | |||
| 98 | + | const cleanup = () => { | |||
| 99 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 100 | + | delete (window as any)[successCallback]; | |||
| 101 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 102 | + | delete (window as any)[errorCallback]; | |||
| 103 | + | }; | |||
| 104 | + | ||||
| 105 | + | // Set up callbacks on window with automatic cleanup | |||
| 106 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 107 | + | (window as any)[successCallback] = (response: TResponse) => { | |||
| 108 | + | cleanup(); | |||
| 109 | + | onSuccess(response); | |||
| 110 | + | }; | |||
| 111 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 112 | + | (window as any)[errorCallback] = (msg: string) => { | |||
| 113 | + | cleanup(); | |||
| 114 | + | onError(typeof msg === "string" ? msg : "Unknown error"); | |||
| 115 | + | }; | |||
| 116 | + | ||||
| 117 | + | try { | |||
| 118 | + | postMessage({ | |||
| 119 | + | type, | |||
| 120 | + | webviewId: getWebViewId(), | |||
| 121 | + | payload, | |||
| 122 | + | }); | |||
| 123 | + | } catch (err) { | |||
| 124 | + | cleanup(); | |||
| 125 | + | const message = err instanceof Error ? err.message : String(err); | |||
| 126 | + | onError(message); | |||
| 127 | + | } | |||
| 128 | + | } | |||
M webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +12 -2
| @@ -2,16 +2,26 @@ | |||||
| 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | ||
| 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | ||
| 4 | import { cn } from "$lib/utils.js"; | 4 | import { cn } from "$lib/utils.js"; | ||
| 5 | + | import type { Snippet } from "svelte"; | |||
| 6 | + | import type { MouseEventHandler } from "svelte/elements"; | |||
| 5 | 7 | ||||
| 6 | let { | 8 | let { | ||
| 7 | ref = $bindable(null), | 9 | ref = $bindable(null), | ||
| 8 | class: className, | 10 | class: className, | ||
| 11 | + | children, | |||
| 12 | + | onclick, | |||
| 9 | ...restProps | 13 | ...restProps | ||
| 10 | - | }: AlertDialogPrimitive.ActionProps = $props(); | 14 | + | }: AlertDialogPrimitive.ActionProps & { |
| 15 | + | children?: Snippet; | |||
| 16 | + | onclick?: MouseEventHandler<HTMLButtonElement>; | |||
| 17 | + | } = $props(); | |||
| 11 | </script> | 18 | </script> | ||
| 12 | 19 | ||||
| 13 | <AlertDialogPrimitive.Action | 20 | <AlertDialogPrimitive.Action | ||
| 14 | bind:ref | 21 | bind:ref | ||
| 15 | class={cn(buttonVariants(), className)} | 22 | class={cn(buttonVariants(), className)} | ||
| 23 | + | {onclick} | |||
| 16 | {...restProps} | 24 | {...restProps} | ||
| 17 | - | /> | 25 | + | > |
| 26 | + | {@render children?.()} | |||
| 27 | + | </AlertDialogPrimitive.Action> | |||
M webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +8 -2
| @@ -2,16 +2,22 @@ | |||||
| 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | ||
| 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | ||
| 4 | import { cn } from "$lib/utils.js"; | 4 | import { cn } from "$lib/utils.js"; | ||
| 5 | + | import type { Snippet } from "svelte"; | |||
| 5 | 6 | ||||
| 6 | let { | 7 | let { | ||
| 7 | ref = $bindable(null), | 8 | ref = $bindable(null), | ||
| 8 | class: className, | 9 | class: className, | ||
| 10 | + | children, | |||
| 9 | ...restProps | 11 | ...restProps | ||
| 10 | - | }: AlertDialogPrimitive.CancelProps = $props(); | 12 | + | }: AlertDialogPrimitive.CancelProps & { |
| 13 | + | children?: Snippet; | |||
| 14 | + | } = $props(); | |||
| 11 | </script> | 15 | </script> | ||
| 12 | 16 | ||||
| 13 | <AlertDialogPrimitive.Cancel | 17 | <AlertDialogPrimitive.Cancel | ||
| 14 | bind:ref | 18 | bind:ref | ||
| 15 | class={cn(buttonVariants({ variant: "outline" }), className)} | 19 | class={cn(buttonVariants({ variant: "outline" }), className)} | ||
| 16 | {...restProps} | 20 | {...restProps} | ||
| 17 | - | /> | 21 | + | > |
| 22 | + | {@render children?.()} | |||
| 23 | + | </AlertDialogPrimitive.Cancel> | |||
M webui/src/lib/components/ui/color-picker/color-picker.svelte +2 -8
| @@ -21,17 +21,11 @@ | |||||
| 21 | 21 | ||||
| 22 | let open = $state(false); | 22 | let open = $state(false); | ||
| 23 | 23 | ||||
| 24 | - | // svelte-awesome-color-picker uses hex binding | 24 | + | // svelte-awesome-color-picker uses hex binding - use writable derived to sync with value |
| 25 | - | let hex = $state(value); | 25 | + | let hex = $derived.by(() => value); |
| 26 | - | ||||
| 27 | - | // Sync hex when value changes externally | |||
| 28 | - | $effect(() => { | |||
| 29 | - | hex = value; | |||
| 30 | - | }); | |||
| 31 | 26 | ||||
| 32 | function handleInput(event: { hex: string | null }) { | 27 | function handleInput(event: { hex: string | null }) { | ||
| 33 | if (event.hex) { | 28 | if (event.hex) { | ||
| 34 | - | hex = event.hex; | |||
| 35 | value = event.hex; | 29 | value = event.hex; | ||
| 36 | onValueChange?.(event.hex); | 30 | onValueChange?.(event.hex); | ||
| 37 | } | 31 | } | ||
A webui/src/lib/utils/keys.ts +51 -0
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Format a keybinding string for display. | |||
| 3 | + | * Converts internal key names to user-friendly display format. | |||
| 4 | + | * | |||
| 5 | + | * Examples: | |||
| 6 | + | * "ctrl+shift+a" -> "Ctrl + Shift + A" | |||
| 7 | + | * "arrowleft" -> "Left" | |||
| 8 | + | * "alt+[" -> "Alt + [" | |||
| 9 | + | */ | |||
| 10 | + | export function formatKey(key: string): string { | |||
| 11 | + | // Split by '+' separator first, then format each part | |||
| 12 | + | // This avoids replacing '+' characters that are actual key names | |||
| 13 | + | const parts = key.split("+"); | |||
| 14 | + | ||||
| 15 | + | const formattedParts = parts.map((part) => { | |||
| 16 | + | const lower = part.toLowerCase(); | |||
| 17 | + | switch (lower) { | |||
| 18 | + | case "arrowleft": | |||
| 19 | + | return "Left"; | |||
| 20 | + | case "arrowright": | |||
| 21 | + | return "Right"; | |||
| 22 | + | case "arrowup": | |||
| 23 | + | return "Up"; | |||
| 24 | + | case "arrowdown": | |||
| 25 | + | return "Down"; | |||
| 26 | + | case "ctrl": | |||
| 27 | + | return "Ctrl"; | |||
| 28 | + | case "alt": | |||
| 29 | + | return "Alt"; | |||
| 30 | + | case "shift": | |||
| 31 | + | return "Shift"; | |||
| 32 | + | case "space": | |||
| 33 | + | return "Space"; | |||
| 34 | + | case "enter": | |||
| 35 | + | return "Enter"; | |||
| 36 | + | case "tab": | |||
| 37 | + | return "Tab"; | |||
| 38 | + | case "backspace": | |||
| 39 | + | return "Backspace"; | |||
| 40 | + | case "escape": | |||
| 41 | + | return "Esc"; | |||
| 42 | + | default: | |||
| 43 | + | // Capitalize single letters, leave symbols as-is | |||
| 44 | + | return part.length === 1 && /[a-z]/i.test(part) | |||
| 45 | + | ? part.toUpperCase() | |||
| 46 | + | : part; | |||
| 47 | + | } | |||
| 48 | + | }); | |||
| 49 | + | ||||
| 50 | + | return formattedParts.join(" + "); | |||
| 51 | + | } | |||
M webui/src/pages/ConfigPage.svelte +60 -42
| @@ -3,6 +3,7 @@ | |||||
| 3 | import { ModeWatcher } from "mode-watcher"; | 3 | import { ModeWatcher } from "mode-watcher"; | ||
| 4 | import ConfigShell from "./config/ConfigShell.svelte"; | 4 | import ConfigShell from "./config/ConfigShell.svelte"; | ||
| 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | ||
| 6 | + | import KeybindingsTab from "./config/KeybindingsTab.svelte"; | |||
| 6 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | 7 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | ||
| 7 | import { Button } from "$lib/components/ui/button"; | 8 | import { Button } from "$lib/components/ui/button"; | ||
| 8 | import { ColorPicker } from "$lib/components/ui/color-picker"; | 9 | import { ColorPicker } from "$lib/components/ui/color-picker"; | ||
| @@ -13,6 +14,12 @@ | |||||
| 13 | import * as Tabs from "$lib/components/ui/tabs"; | 14 | import * as Tabs from "$lib/components/ui/tabs"; | ||
| 14 | import { FlaskConical, RefreshCw } from "@lucide/svelte"; | 15 | import { FlaskConical, RefreshCw } from "@lucide/svelte"; | ||
| 15 | 16 | ||||
| 17 | + | function getErrorMessage(e: unknown): string { | |||
| 18 | + | if (e instanceof Error) return e.message; | |||
| 19 | + | if (typeof e === "string") return e; | |||
| 20 | + | return "Unknown error"; | |||
| 21 | + | } | |||
| 22 | + | ||||
| 16 | type ColorPalette = { | 23 | type ColorPalette = { | ||
| 17 | background: string; | 24 | background: string; | ||
| 18 | surface: string; | 25 | surface: string; | ||
| @@ -118,8 +125,8 @@ | |||||
| 118 | const response = await fetch("/api/config"); | 125 | const response = await fetch("/api/config"); | ||
| 119 | if (!response.ok) throw new Error("Failed to fetch config"); | 126 | if (!response.ok) throw new Error("Failed to fetch config"); | ||
| 120 | config = (await response.json()) as ConfigDTO; | 127 | config = (await response.json()) as ConfigDTO; | ||
| 121 | - | } catch (e: any) { | 128 | + | } catch (e: unknown) { |
| 122 | - | loadError = e.message; | 129 | + | loadError = getErrorMessage(e); |
| 123 | console.error("[config] load failed", e); | 130 | console.error("[config] load failed", e); | ||
| 124 | } finally { | 131 | } finally { | ||
| 125 | loading = false; | 132 | loading = false; | ||
| @@ -139,14 +146,14 @@ | |||||
| 139 | const response = await fetch("/api/config/default"); | 146 | const response = await fetch("/api/config/default"); | ||
| 140 | if (!response.ok) throw new Error("Failed to fetch default config"); | 147 | if (!response.ok) throw new Error("Failed to fetch default config"); | ||
| 141 | config = (await response.json()) as ConfigDTO; | 148 | config = (await response.json()) as ConfigDTO; | ||
| 142 | - | } catch (e: any) { | 149 | + | } catch (e: unknown) { |
| 143 | console.error("[config] reset defaults failed", e); | 150 | console.error("[config] reset defaults failed", e); | ||
| 144 | } | 151 | } | ||
| 145 | resetDialogOpen = false; | 152 | resetDialogOpen = false; | ||
| 146 | } | 153 | } | ||
| 147 | 154 | ||||
| 148 | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | 155 | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | ||
| 149 | - | const bridge = (window as any).webkit?.messageHandlers?.dumber; | 156 | + | const bridge = window.webkit?.messageHandlers?.dumber; |
| 150 | if (bridge && typeof bridge.postMessage === "function") { | 157 | if (bridge && typeof bridge.postMessage === "function") { | ||
| 151 | return bridge; | 158 | return bridge; | ||
| 152 | } | 159 | } | ||
| @@ -154,7 +161,7 @@ | |||||
| 154 | } | 161 | } | ||
| 155 | 162 | ||||
| 156 | function getWebViewId(): number { | 163 | function getWebViewId(): number { | ||
| 157 | - | return (window as any).__dumber_webview_id || 0; | 164 | + | return window.__dumber_webview_id || 0; |
| 158 | } | 165 | } | ||
| 159 | 166 | ||||
| 160 | async function saveConfig() { | 167 | async function saveConfig() { | ||
| @@ -194,7 +201,7 @@ | |||||
| 194 | } | 201 | } | ||
| 195 | }; | 202 | }; | ||
| 196 | 203 | ||||
| 197 | - | (window as any).__dumber_config_saved = (resp?: unknown) => { | 204 | + | window.__dumber_config_saved = (resp?: unknown) => { |
| 198 | clearSaveTimeout(); | 205 | clearSaveTimeout(); | ||
| 199 | console.debug("[config] save success", resp); | 206 | console.debug("[config] save success", resp); | ||
| 200 | saveSuccess = true; | 207 | saveSuccess = true; | ||
| @@ -212,7 +219,7 @@ | |||||
| 212 | saveSuccess = false; | 219 | saveSuccess = false; | ||
| 213 | }, 3000); | 220 | }, 3000); | ||
| 214 | }; | 221 | }; | ||
| 215 | - | (window as any).__dumber_config_error = (msg: unknown) => { | 222 | + | window.__dumber_config_error = (msg: unknown) => { |
| 216 | clearSaveTimeout(); | 223 | clearSaveTimeout(); | ||
| 217 | console.error("[config] save error", msg); | 224 | console.error("[config] save error", msg); | ||
| 218 | saveError = typeof msg === "string" ? msg : "Failed to save config"; | 225 | saveError = typeof msg === "string" ? msg : "Failed to save config"; | ||
| @@ -227,14 +234,13 @@ | |||||
| 227 | webview_id: webviewId, | 234 | webview_id: webviewId, | ||
| 228 | payload, | 235 | payload, | ||
| 229 | }); | 236 | }); | ||
| 230 | - | console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length }); | |||
| 231 | } catch (postErr) { | 237 | } catch (postErr) { | ||
| 232 | console.error("[config] postMessage threw", postErr); | 238 | console.error("[config] postMessage threw", postErr); | ||
| 233 | throw postErr; | 239 | throw postErr; | ||
| 234 | } | 240 | } | ||
| 235 | - | } catch (e: any) { | 241 | + | } catch (e: unknown) { |
| 236 | console.error("[config] save exception", e); | 242 | console.error("[config] save exception", e); | ||
| 237 | - | saveError = e.message; | 243 | + | saveError = getErrorMessage(e); |
| 238 | saving = false; | 244 | saving = false; | ||
| 239 | } | 245 | } | ||
| 240 | } | 246 | } | ||
| @@ -246,8 +252,8 @@ | |||||
| 246 | 252 | ||||
| 247 | onMount(() => { | 253 | onMount(() => { | ||
| 248 | console.debug("[config] mount", { | 254 | console.debug("[config] mount", { | ||
| 249 | - | webviewId: (window as any).__dumber_webview_id, | 255 | + | webviewId: window.__dumber_webview_id, |
| 250 | - | hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber), | 256 | + | hasBridge: Boolean(window.webkit?.messageHandlers?.dumber), |
| 251 | }); | 257 | }); | ||
| 252 | 258 | ||||
| 253 | const onThemeChanged = (e: Event) => { | 259 | const onThemeChanged = (e: Event) => { | ||
| @@ -298,6 +304,7 @@ | |||||
| 298 | <Tabs.List class="bg-muted/60"> | 304 | <Tabs.List class="bg-muted/60"> | ||
| 299 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | 305 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | ||
| 300 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | 306 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | ||
| 307 | + | <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger> | |||
| 301 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | 308 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | ||
| 302 | </Tabs.List> | 309 | </Tabs.List> | ||
| 303 | </div> | 310 | </div> | ||
| @@ -340,7 +347,12 @@ | |||||
| 340 | </div> | 347 | </div> | ||
| 341 | <div class="space-y-2"> | 348 | <div class="space-y-2"> | ||
| 342 | <Label for="ui_scale">UI Scale</Label> | 349 | <Label for="ui_scale">UI Scale</Label> | ||
| 343 | - | <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} /> | 350 | + | <Input |
| 351 | + | id="ui_scale" | |||
| 352 | + | type="number" | |||
| 353 | + | step="0.1" | |||
| 354 | + | bind:value={config.default_ui_scale} | |||
| 355 | + | /> | |||
| 344 | </div> | 356 | </div> | ||
| 345 | </div> | 357 | </div> | ||
| 346 | 358 | ||||
| @@ -424,6 +436,10 @@ | |||||
| 424 | </Card.Root> | 436 | </Card.Root> | ||
| 425 | </Tabs.Content> | 437 | </Tabs.Content> | ||
| 426 | 438 | ||||
| 439 | + | <Tabs.Content value="keybindings"> | |||
| 440 | + | <KeybindingsTab /> | |||
| 441 | + | </Tabs.Content> | |||
| 442 | + | ||||
| 427 | <Tabs.Content value="performance"> | 443 | <Tabs.Content value="performance"> | ||
| 428 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | 444 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | ||
| 429 | <Card.Header> | 445 | <Card.Header> | ||
| @@ -614,35 +630,37 @@ | |||||
| 614 | <span>Theme x{themeEvents}</span> | 630 | <span>Theme x{themeEvents}</span> | ||
| 615 | {/if} | 631 | {/if} | ||
| 616 | </div> | 632 | </div> | ||
| 617 | - | <div class="flex items-center gap-3"> | 633 | + | {#if activeTab !== "keybindings"} |
| 618 | - | <AlertDialog.Root bind:open={resetDialogOpen}> | 634 | + | <div class="flex items-center gap-3"> |
| 619 | - | <AlertDialog.Trigger disabled={saving}> | 635 | + | <AlertDialog.Root bind:open={resetDialogOpen}> |
| 620 | - | {#snippet child({ props })} | 636 | + | <AlertDialog.Trigger disabled={saving}> |
| 621 | - | <Button variant="outline" {...props} type="button"> | 637 | + | {#snippet child({ props })} |
| 622 | - | Reset Defaults | 638 | + | <Button variant="outline" {...props} type="button"> |
| 623 | - | </Button> | 639 | + | Reset Defaults |
| 624 | - | {/snippet} | 640 | + | </Button> |
| 625 | - | </AlertDialog.Trigger> | 641 | + | {/snippet} |
| 626 | - | <AlertDialog.Content> | 642 | + | </AlertDialog.Trigger> |
| 627 | - | <AlertDialog.Header> | 643 | + | <AlertDialog.Content> |
| 628 | - | <AlertDialog.Title>Reset to defaults?</AlertDialog.Title> | 644 | + | <AlertDialog.Header> |
| 629 | - | <AlertDialog.Description> | 645 | + | <AlertDialog.Title>Reset to defaults?</AlertDialog.Title> |
| 630 | - | This will reset all settings on this page to their default values. | 646 | + | <AlertDialog.Description> |
| 631 | - | You will still need to click Save to persist the changes. | 647 | + | This will reset all settings on this page to their default values. |
| 632 | - | </AlertDialog.Description> | 648 | + | You will still need to click Save to persist the changes. |
| 633 | - | </AlertDialog.Header> | 649 | + | </AlertDialog.Description> |
| 634 | - | <AlertDialog.Footer> | 650 | + | </AlertDialog.Header> |
| 635 | - | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | 651 | + | <AlertDialog.Footer> |
| 636 | - | <AlertDialog.Action onclick={doResetToDefaults}> | 652 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> |
| 637 | - | Reset Defaults | 653 | + | <AlertDialog.Action onclick={doResetToDefaults}> |
| 638 | - | </AlertDialog.Action> | 654 | + | Reset Defaults |
| 639 | - | </AlertDialog.Footer> | 655 | + | </AlertDialog.Action> |
| 640 | - | </AlertDialog.Content> | 656 | + | </AlertDialog.Footer> |
| 641 | - | </AlertDialog.Root> | 657 | + | </AlertDialog.Content> |
| 642 | - | <Button onclick={saveConfig} disabled={saving} type="button"> | 658 | + | </AlertDialog.Root> |
| 643 | - | {saving ? "Saving…" : "Save"} | 659 | + | <Button onclick={saveConfig} disabled={saving} type="button"> |
| 644 | - | </Button> | 660 | + | {saving ? "Saving…" : "Save"} |
| 645 | - | </div> | 661 | + | </Button> |
| 662 | + | </div> | |||
| 663 | + | {/if} | |||
| 646 | </div> | 664 | </div> | ||
| 647 | 665 | ||||
| 648 | <!-- Save error display --> | 666 | <!-- Save error display --> | ||
M webui/src/pages/ErrorPage.svelte +11 -2
| @@ -70,7 +70,11 @@ | |||||
| 70 | const handler = (event: MessageEvent) => { | 70 | const handler = (event: MessageEvent) => { | ||
| 71 | if (event.data?.requestId === requestId) { | 71 | if (event.data?.requestId === requestId) { | ||
| 72 | window.removeEventListener('message', handler); | 72 | window.removeEventListener('message', handler); | ||
| 73 | - | event.data.success ? resolve(event.data.data) : reject(new Error(event.data.error || 'Unknown error')); | 73 | + | if (event.data.success) { |
| 74 | + | resolve(event.data.data); | |||
| 75 | + | } else { | |||
| 76 | + | reject(new Error(event.data.error || 'Unknown error')); | |||
| 77 | + | } | |||
| 74 | } | 78 | } | ||
| 75 | }; | 79 | }; | ||
| 76 | window.addEventListener('message', handler); | 80 | window.addEventListener('message', handler); | ||
| @@ -110,7 +114,11 @@ | |||||
| 110 | } | 114 | } | ||
| 111 | 115 | ||||
| 112 | function retry() { | 116 | function retry() { | ||
| 113 | - | targetUrl ? window.location.href = targetUrl : window.location.reload(); | 117 | + | if (targetUrl) { |
| 118 | + | window.location.href = targetUrl; | |||
| 119 | + | } else { | |||
| 120 | + | window.location.reload(); | |||
| 121 | + | } | |||
| 114 | } | 122 | } | ||
| 115 | 123 | ||||
| 116 | function goBack() { | 124 | function goBack() { | ||
| @@ -144,6 +152,7 @@ | |||||
| 144 | <Card.Header class="text-center"> | 152 | <Card.Header class="text-center"> | ||
| 145 | <div class="flex justify-center mb-4"> | 153 | <div class="flex justify-center mb-4"> | ||
| 146 | <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | 154 | <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | ||
| 155 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths --> | |||
| 147 | {@html icons[config.icon] || icons.error} | 156 | {@html icons[config.icon] || icons.error} | ||
| 148 | </svg> | 157 | </svg> | ||
| 149 | </div> | 158 | </div> | ||
M webui/src/pages/config/ConfigShell.svelte +3 -4
| @@ -9,14 +9,12 @@ | |||||
| 9 | let { children }: Props = $props(); | 9 | let { children }: Props = $props(); | ||
| 10 | 10 | ||||
| 11 | function navigateWithViewTransition(url: string) { | 11 | function navigateWithViewTransition(url: string) { | ||
| 12 | - | const doc = document as any; | 12 | + | if (typeof document.startViewTransition !== 'function') { |
| 13 | - | ||||
| 14 | - | if (typeof doc?.startViewTransition !== 'function') { | |||
| 15 | window.location.href = url; | 13 | window.location.href = url; | ||
| 16 | return; | 14 | return; | ||
| 17 | } | 15 | } | ||
| 18 | 16 | ||||
| 19 | - | const transition = doc.startViewTransition(() => { | 17 | + | const transition = document.startViewTransition(() => { |
| 20 | document.documentElement.dataset.vt = 'leaving'; | 18 | document.documentElement.dataset.vt = 'leaving'; | ||
| 21 | }); | 19 | }); | ||
| 22 | 20 | ||||
| @@ -34,6 +32,7 @@ | |||||
| 34 | <svelte:head> | 32 | <svelte:head> | ||
| 35 | <title>dumb://config</title> | 33 | <title>dumb://config</title> | ||
| 36 | <meta name="description" content="Dumber Browser - Settings" /> | 34 | <meta name="description" content="Dumber Browser - Settings" /> | ||
| 35 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization --> | |||
| 37 | {@html `<style> | 36 | {@html `<style> | ||
| 38 | html, body { margin: 0; padding: 0; } | 37 | html, body { margin: 0; padding: 0; } | ||
| 39 | html { background: var(--background, #0a0a0a); } | 38 | html { background: var(--background, #0a0a0a); } | ||
A webui/src/pages/config/KeyCaptureModal.svelte +207 -0
| @@ -0,0 +1,207 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount, untrack } from "svelte"; | |||
| 3 | + | import { Button } from "$lib/components/ui/button"; | |||
| 4 | + | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |||
| 5 | + | import { Keyboard, X, Plus } from "@lucide/svelte"; | |||
| 6 | + | import { formatKey } from "$lib/utils/keys"; | |||
| 7 | + | ||||
| 8 | + | type KeybindingEntry = { | |||
| 9 | + | action: string; | |||
| 10 | + | description: string; | |||
| 11 | + | keys: string[]; | |||
| 12 | + | default_keys: string[]; | |||
| 13 | + | is_custom: boolean; | |||
| 14 | + | }; | |||
| 15 | + | ||||
| 16 | + | interface Props { | |||
| 17 | + | binding: KeybindingEntry; | |||
| 18 | + | onSave: (keys: string[]) => void; | |||
| 19 | + | onCancel: () => void; | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | let { binding, onSave, onCancel }: Props = $props(); | |||
| 23 | + | ||||
| 24 | + | // Initialize with a plain copy of the binding keys using $state.snapshot() | |||
| 25 | + | // to strip Svelte 5 Proxy wrappers. untrack() ensures we only read the initial value. | |||
| 26 | + | let newKeys = $state<string[]>( | |||
| 27 | + | untrack(() => $state.snapshot(binding.keys) as string[]) | |||
| 28 | + | ); | |||
| 29 | + | let isCapturing = $state(false); | |||
| 30 | + | let capturedKey = $state<string | null>(null); | |||
| 31 | + | let isSaving = $state(false); | |||
| 32 | + | ||||
| 33 | + | // Symbol keys that are already shifted (pressing shift produces these characters) | |||
| 34 | + | // Don't add 'shift' modifier for these since they're self-describing | |||
| 35 | + | const SHIFTED_SYMBOLS = new Set([ | |||
| 36 | + | "{", "}", "(", ")", "<", ">", "!", "@", "#", "$", "%", "^", "&", "*", | |||
| 37 | + | "+", "_", ":", '"', "?", "|", "~", | |||
| 38 | + | ]); | |||
| 39 | + | ||||
| 40 | + | function handleKeyDown(e: KeyboardEvent) { | |||
| 41 | + | if (!isCapturing) return; | |||
| 42 | + | e.preventDefault(); | |||
| 43 | + | e.stopPropagation(); | |||
| 44 | + | ||||
| 45 | + | const parts: string[] = []; | |||
| 46 | + | if (e.ctrlKey) parts.push("ctrl"); | |||
| 47 | + | if (e.altKey) parts.push("alt"); | |||
| 48 | + | ||||
| 49 | + | let key = e.key.toLowerCase(); | |||
| 50 | + | switch (key) { | |||
| 51 | + | case "control": | |||
| 52 | + | case "alt": | |||
| 53 | + | case "shift": | |||
| 54 | + | case "meta": | |||
| 55 | + | return; // Modifier only, wait for actual key | |||
| 56 | + | case "arrowleft": | |||
| 57 | + | key = "arrowleft"; | |||
| 58 | + | break; | |||
| 59 | + | case "arrowright": | |||
| 60 | + | key = "arrowright"; | |||
| 61 | + | break; | |||
| 62 | + | case "arrowup": | |||
| 63 | + | key = "arrowup"; | |||
| 64 | + | break; | |||
| 65 | + | case "arrowdown": | |||
| 66 | + | key = "arrowdown"; | |||
| 67 | + | break; | |||
| 68 | + | case "escape": | |||
| 69 | + | // Escape closes the modal entirely | |||
| 70 | + | onCancel(); | |||
| 71 | + | return; | |||
| 72 | + | case " ": | |||
| 73 | + | key = "space"; | |||
| 74 | + | break; | |||
| 75 | + | case "enter": | |||
| 76 | + | key = "enter"; | |||
| 77 | + | break; | |||
| 78 | + | case "tab": | |||
| 79 | + | key = "tab"; | |||
| 80 | + | break; | |||
| 81 | + | case "backspace": | |||
| 82 | + | key = "backspace"; | |||
| 83 | + | break; | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Only add shift modifier for letter keys and non-symbol keys | |||
| 87 | + | // Symbol keys like { } + etc. are already the shifted result | |||
| 88 | + | if (e.shiftKey && !SHIFTED_SYMBOLS.has(e.key)) { | |||
| 89 | + | parts.push("shift"); | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | parts.push(key); | |||
| 93 | + | capturedKey = parts.join("+"); | |||
| 94 | + | isCapturing = false; | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | function addCapturedKey() { | |||
| 98 | + | if (capturedKey && !newKeys.includes(capturedKey)) { | |||
| 99 | + | newKeys = [...newKeys, capturedKey]; | |||
| 100 | + | } | |||
| 101 | + | capturedKey = null; | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | function removeKey(index: number) { | |||
| 105 | + | newKeys = newKeys.filter((_, i) => i !== index); | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | function handleSave() { | |||
| 109 | + | // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers | |||
| 110 | + | const keysToSave = $state.snapshot(newKeys) as string[]; | |||
| 111 | + | // Auto-add any pending captured key before saving | |||
| 112 | + | if (capturedKey && !keysToSave.includes(capturedKey)) { | |||
| 113 | + | keysToSave.push(capturedKey); | |||
| 114 | + | } | |||
| 115 | + | isSaving = true; | |||
| 116 | + | try { | |||
| 117 | + | onSave(keysToSave); | |||
| 118 | + | } finally { | |||
| 119 | + | // Reset isSaving so modal remains dismissible if save fails | |||
| 120 | + | isSaving = false; | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | onMount(() => { | |||
| 125 | + | const handler = (e: KeyboardEvent) => handleKeyDown(e); | |||
| 126 | + | window.addEventListener("keydown", handler, true); | |||
| 127 | + | return () => window.removeEventListener("keydown", handler, true); | |||
| 128 | + | }); | |||
| 129 | + | </script> | |||
| 130 | + | ||||
| 131 | + | <AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}> | |||
| 132 | + | <AlertDialog.Content class="max-w-md"> | |||
| 133 | + | <AlertDialog.Header> | |||
| 134 | + | <AlertDialog.Title>Edit Keybinding</AlertDialog.Title> | |||
| 135 | + | <AlertDialog.Description> | |||
| 136 | + | Configure keybindings for <strong>{binding.action}</strong> | |||
| 137 | + | </AlertDialog.Description> | |||
| 138 | + | </AlertDialog.Header> | |||
| 139 | + | ||||
| 140 | + | <div class="space-y-4 py-4"> | |||
| 141 | + | <!-- Current bindings --> | |||
| 142 | + | <div class="space-y-2"> | |||
| 143 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 144 | + | Current Bindings | |||
| 145 | + | </div> | |||
| 146 | + | <div class="flex flex-wrap gap-2"> | |||
| 147 | + | {#each newKeys as key, i (key)} | |||
| 148 | + | <div class="flex items-center gap-1 rounded bg-muted px-2 py-1"> | |||
| 149 | + | <kbd class="text-xs font-mono">{formatKey(key)}</kbd> | |||
| 150 | + | <button | |||
| 151 | + | type="button" | |||
| 152 | + | onclick={() => removeKey(i)} | |||
| 153 | + | class="text-muted-foreground hover:text-foreground" | |||
| 154 | + | > | |||
| 155 | + | <X class="size-3" /> | |||
| 156 | + | </button> | |||
| 157 | + | </div> | |||
| 158 | + | {/each} | |||
| 159 | + | {#if newKeys.length === 0} | |||
| 160 | + | <span class="text-sm text-muted-foreground">(no bindings)</span> | |||
| 161 | + | {/if} | |||
| 162 | + | </div> | |||
| 163 | + | </div> | |||
| 164 | + | ||||
| 165 | + | <!-- Capture area --> | |||
| 166 | + | <div class="space-y-2"> | |||
| 167 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 168 | + | Add New Binding | |||
| 169 | + | </div> | |||
| 170 | + | <div class="flex gap-2"> | |||
| 171 | + | <button | |||
| 172 | + | type="button" | |||
| 173 | + | onclick={() => { isCapturing = true; capturedKey = null; }} | |||
| 174 | + | class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}" | |||
| 175 | + | > | |||
| 176 | + | <Keyboard class="size-5" /> | |||
| 177 | + | {#if isCapturing} | |||
| 178 | + | Press a key combination... | |||
| 179 | + | {:else if capturedKey} | |||
| 180 | + | <kbd class="font-mono">{formatKey(capturedKey)}</kbd> | |||
| 181 | + | {:else} | |||
| 182 | + | Click to capture key | |||
| 183 | + | {/if} | |||
| 184 | + | </button> | |||
| 185 | + | {#if capturedKey} | |||
| 186 | + | <Button variant="outline" onclick={addCapturedKey}> | |||
| 187 | + | <Plus class="mr-1 size-4" /> | |||
| 188 | + | Add | |||
| 189 | + | </Button> | |||
| 190 | + | {/if} | |||
| 191 | + | </div> | |||
| 192 | + | </div> | |||
| 193 | + | ||||
| 194 | + | <!-- Default hint --> | |||
| 195 | + | {#if binding.default_keys.length > 0} | |||
| 196 | + | <div class="text-xs text-muted-foreground"> | |||
| 197 | + | Default: {binding.default_keys.map(formatKey).join(", ")} | |||
| 198 | + | </div> | |||
| 199 | + | {/if} | |||
| 200 | + | </div> | |||
| 201 | + | ||||
| 202 | + | <AlertDialog.Footer> | |||
| 203 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |||
| 204 | + | <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action> | |||
| 205 | + | </AlertDialog.Footer> | |||
| 206 | + | </AlertDialog.Content> | |||
| 207 | + | </AlertDialog.Root> | |||
A webui/src/pages/config/KeybindingGroup.svelte +95 -0
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { Button } from "$lib/components/ui/button"; | |||
| 3 | + | import * as Table from "$lib/components/ui/table"; | |||
| 4 | + | import { RotateCcw, Edit3 } from "@lucide/svelte"; | |||
| 5 | + | import { formatKey } from "$lib/utils/keys"; | |||
| 6 | + | ||||
| 7 | + | type KeybindingEntry = { | |||
| 8 | + | action: string; | |||
| 9 | + | description: string; | |||
| 10 | + | keys: string[]; | |||
| 11 | + | default_keys: string[]; | |||
| 12 | + | is_custom: boolean; | |||
| 13 | + | }; | |||
| 14 | + | ||||
| 15 | + | type KeybindingGroupData = { | |||
| 16 | + | mode: string; | |||
| 17 | + | display_name: string; | |||
| 18 | + | bindings: KeybindingEntry[]; | |||
| 19 | + | activation?: string; | |||
| 20 | + | }; | |||
| 21 | + | ||||
| 22 | + | interface Props { | |||
| 23 | + | group: KeybindingGroupData; | |||
| 24 | + | onEdit: (action: string, entry: KeybindingEntry) => void; | |||
| 25 | + | onReset: (action: string) => void; | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | let { group, onEdit, onReset }: Props = $props(); | |||
| 29 | + | </script> | |||
| 30 | + | ||||
| 31 | + | <div class="space-y-3"> | |||
| 32 | + | <div class="flex items-center gap-2"> | |||
| 33 | + | <h3 class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 34 | + | {group.display_name} | |||
| 35 | + | </h3> | |||
| 36 | + | {#if group.activation} | |||
| 37 | + | <span class="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground"> | |||
| 38 | + | {formatKey(group.activation)} | |||
| 39 | + | </span> | |||
| 40 | + | {/if} | |||
| 41 | + | </div> | |||
| 42 | + | ||||
| 43 | + | <Table.Root class="rounded-md border border-border"> | |||
| 44 | + | <Table.Header> | |||
| 45 | + | <Table.Row> | |||
| 46 | + | <Table.Head class="w-[180px]">Action</Table.Head> | |||
| 47 | + | <Table.Head>Description</Table.Head> | |||
| 48 | + | <Table.Head class="w-[200px]">Keybinding</Table.Head> | |||
| 49 | + | <Table.Head class="w-[100px] text-right">Actions</Table.Head> | |||
| 50 | + | </Table.Row> | |||
| 51 | + | </Table.Header> | |||
| 52 | + | <Table.Body> | |||
| 53 | + | {#each group.bindings as binding (binding.action)} | |||
| 54 | + | <Table.Row class={binding.is_custom ? "bg-accent/5" : ""}> | |||
| 55 | + | <Table.Cell class="font-mono text-sm">{binding.action}</Table.Cell> | |||
| 56 | + | <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell> | |||
| 57 | + | <Table.Cell> | |||
| 58 | + | <div class="flex flex-wrap gap-1"> | |||
| 59 | + | {#each binding.keys as key (key)} | |||
| 60 | + | <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"> | |||
| 61 | + | {formatKey(key)} | |||
| 62 | + | </kbd> | |||
| 63 | + | {/each} | |||
| 64 | + | {#if binding.keys.length === 0} | |||
| 65 | + | <span class="text-xs text-muted-foreground">(unbound)</span> | |||
| 66 | + | {/if} | |||
| 67 | + | </div> | |||
| 68 | + | </Table.Cell> | |||
| 69 | + | <Table.Cell class="text-right"> | |||
| 70 | + | <div class="flex justify-end gap-1"> | |||
| 71 | + | <Button | |||
| 72 | + | variant="ghost" | |||
| 73 | + | size="icon" | |||
| 74 | + | onclick={() => onEdit(binding.action, binding)} | |||
| 75 | + | aria-label="Edit binding" | |||
| 76 | + | > | |||
| 77 | + | <Edit3 class="size-4" /> | |||
| 78 | + | </Button> | |||
| 79 | + | {#if binding.is_custom} | |||
| 80 | + | <Button | |||
| 81 | + | variant="ghost" | |||
| 82 | + | size="icon" | |||
| 83 | + | onclick={() => onReset(binding.action)} | |||
| 84 | + | aria-label="Reset to default" | |||
| 85 | + | > | |||
| 86 | + | <RotateCcw class="size-4" /> | |||
| 87 | + | </Button> | |||
| 88 | + | {/if} | |||
| 89 | + | </div> | |||
| 90 | + | </Table.Cell> | |||
| 91 | + | </Table.Row> | |||
| 92 | + | {/each} | |||
| 93 | + | </Table.Body> | |||
| 94 | + | </Table.Root> | |||
| 95 | + | </div> | |||
A webui/src/pages/config/KeybindingsTab.svelte +340 -0
| @@ -0,0 +1,340 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from "svelte"; | |||
| 3 | + | import { Button } from "$lib/components/ui/button"; | |||
| 4 | + | import { Spinner } from "$lib/components/ui/spinner"; | |||
| 5 | + | import * as Card from "$lib/components/ui/card"; | |||
| 6 | + | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |||
| 7 | + | import { RefreshCw, RotateCcw, AlertTriangle, CheckCircle } from "@lucide/svelte"; | |||
| 8 | + | import { postMessage, getWebKitBridge, getWebViewId } from "$lib/bridge"; | |||
| 9 | + | import KeybindingGroup from "./KeybindingGroup.svelte"; | |||
| 10 | + | import KeyCaptureModal from "./KeyCaptureModal.svelte"; | |||
| 11 | + | ||||
| 12 | + | type KeybindingEntry = { | |||
| 13 | + | action: string; | |||
| 14 | + | description: string; | |||
| 15 | + | keys: string[]; | |||
| 16 | + | default_keys: string[]; | |||
| 17 | + | is_custom: boolean; | |||
| 18 | + | }; | |||
| 19 | + | ||||
| 20 | + | type KeybindingGroupData = { | |||
| 21 | + | mode: string; | |||
| 22 | + | display_name: string; | |||
| 23 | + | bindings: KeybindingEntry[]; | |||
| 24 | + | activation?: string; | |||
| 25 | + | }; | |||
| 26 | + | ||||
| 27 | + | type KeybindingsConfig = { | |||
| 28 | + | groups: KeybindingGroupData[]; | |||
| 29 | + | }; | |||
| 30 | + | ||||
| 31 | + | type KeybindingConflict = { | |||
| 32 | + | conflicting_action: string; | |||
| 33 | + | conflicting_mode: string; | |||
| 34 | + | key: string; | |||
| 35 | + | }; | |||
| 36 | + | ||||
| 37 | + | let loading = $state(true); | |||
| 38 | + | let error = $state<string | null>(null); | |||
| 39 | + | let keybindings = $state<KeybindingsConfig | null>(null); | |||
| 40 | + | let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null); | |||
| 41 | + | let resetAllDialogOpen = $state(false); | |||
| 42 | + | let successMessage = $state<string | null>(null); | |||
| 43 | + | let conflicts = $state<KeybindingConflict[]>([]); | |||
| 44 | + | let hasPendingReset = $state(false); | |||
| 45 | + | let savingReset = $state(false); | |||
| 46 | + | ||||
| 47 | + | // Type-safe callback setup helper | |||
| 48 | + | function setupCallback<T>(name: string, handler: (data: T) => void): void { | |||
| 49 | + | (window as unknown as Record<string, (data: T) => void>)[name] = handler; | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | async function loadKeybindings() { | |||
| 53 | + | loading = true; | |||
| 54 | + | error = null; | |||
| 55 | + | ||||
| 56 | + | if (!getWebKitBridge()) { | |||
| 57 | + | error = "WebKit bridge not available"; | |||
| 58 | + | loading = false; | |||
| 59 | + | return; | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | setupCallback<KeybindingsConfig>("__dumber_keybindings_loaded", (data) => { | |||
| 63 | + | keybindings = data; | |||
| 64 | + | loading = false; | |||
| 65 | + | }); | |||
| 66 | + | setupCallback<string>("__dumber_keybindings_error", (msg) => { | |||
| 67 | + | error = typeof msg === "string" ? msg : "Failed to load keybindings"; | |||
| 68 | + | loading = false; | |||
| 69 | + | }); | |||
| 70 | + | ||||
| 71 | + | try { | |||
| 72 | + | postMessage({ | |||
| 73 | + | type: "get_keybindings", | |||
| 74 | + | webviewId: getWebViewId(), | |||
| 75 | + | payload: {}, | |||
| 76 | + | }); | |||
| 77 | + | } catch (err) { | |||
| 78 | + | error = err instanceof Error ? err.message : "Failed to load keybindings"; | |||
| 79 | + | loading = false; | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | function startEdit(mode: string, action: string, entry: KeybindingEntry) { | |||
| 84 | + | editingBinding = { mode, action, entry }; | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | function showSuccess(message: string) { | |||
| 88 | + | successMessage = message; | |||
| 89 | + | setTimeout(() => { successMessage = null; }, 3000); | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | async function saveBinding(keys: string[]) { | |||
| 93 | + | if (!editingBinding) return; | |||
| 94 | + | ||||
| 95 | + | if (!getWebKitBridge()) { | |||
| 96 | + | error = "WebKit bridge not available"; | |||
| 97 | + | return; | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Timeout to detect if backend never responds (message silently dropped) | |||
| 101 | + | const timeoutId = setTimeout(() => { | |||
| 102 | + | error = "Save timed out - backend did not respond"; | |||
| 103 | + | }, 5000); | |||
| 104 | + | ||||
| 105 | + | setupCallback<{ status: string; conflicts?: KeybindingConflict[] }>( | |||
| 106 | + | "__dumber_keybinding_set", | |||
| 107 | + | (response) => { | |||
| 108 | + | clearTimeout(timeoutId); | |||
| 109 | + | if (!response || response.status !== "success") { | |||
| 110 | + | error = "Failed to save keybinding: invalid response"; | |||
| 111 | + | return; | |||
| 112 | + | } | |||
| 113 | + | conflicts = response.conflicts?.length ? response.conflicts : []; | |||
| 114 | + | loadKeybindings(); | |||
| 115 | + | editingBinding = null; | |||
| 116 | + | showSuccess("Keybinding saved successfully"); | |||
| 117 | + | } | |||
| 118 | + | ); | |||
| 119 | + | setupCallback<string>("__dumber_keybinding_set_error", (msg) => { | |||
| 120 | + | clearTimeout(timeoutId); | |||
| 121 | + | error = typeof msg === "string" ? msg : "Failed to save keybinding"; | |||
| 122 | + | }); | |||
| 123 | + | ||||
| 124 | + | // Create plain payload - extract primitive values explicitly to avoid any proxy issues | |||
| 125 | + | const mode = String(editingBinding.mode); | |||
| 126 | + | const action = String(editingBinding.action); | |||
| 127 | + | const payload = { mode, action, keys: [...keys] }; | |||
| 128 | + | ||||
| 129 | + | try { | |||
| 130 | + | postMessage({ | |||
| 131 | + | type: "set_keybinding", | |||
| 132 | + | webviewId: getWebViewId(), | |||
| 133 | + | payload, | |||
| 134 | + | }); | |||
| 135 | + | } catch (err) { | |||
| 136 | + | clearTimeout(timeoutId); | |||
| 137 | + | error = err instanceof Error ? err.message : "Failed to save keybinding"; | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | async function resetBinding(mode: string, action: string) { | |||
| 142 | + | if (!getWebKitBridge()) return; | |||
| 143 | + | ||||
| 144 | + | setupCallback<void>("__dumber_keybinding_reset", () => { | |||
| 145 | + | conflicts = []; | |||
| 146 | + | loadKeybindings(); | |||
| 147 | + | showSuccess("Keybinding reset to default"); | |||
| 148 | + | }); | |||
| 149 | + | setupCallback<string>("__dumber_keybinding_reset_error", (msg) => { | |||
| 150 | + | error = typeof msg === "string" ? msg : "Failed to reset keybinding"; | |||
| 151 | + | }); | |||
| 152 | + | ||||
| 153 | + | try { | |||
| 154 | + | postMessage({ | |||
| 155 | + | type: "reset_keybinding", | |||
| 156 | + | webviewId: getWebViewId(), | |||
| 157 | + | payload: { mode, action }, | |||
| 158 | + | }); | |||
| 159 | + | } catch (err) { | |||
| 160 | + | error = err instanceof Error ? err.message : "Failed to reset keybinding"; | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Reset All: update local state to defaults WITHOUT saving | |||
| 165 | + | function resetAllBindings() { | |||
| 166 | + | if (!keybindings) return; | |||
| 167 | + | ||||
| 168 | + | // Reset each binding's keys to default_keys in local state | |||
| 169 | + | const resetGroups = keybindings.groups.map(group => ({ | |||
| 170 | + | ...group, | |||
| 171 | + | bindings: group.bindings.map(binding => ({ | |||
| 172 | + | ...binding, | |||
| 173 | + | keys: [...binding.default_keys], | |||
| 174 | + | is_custom: false, | |||
| 175 | + | })), | |||
| 176 | + | })); | |||
| 177 | + | ||||
| 178 | + | keybindings = { groups: resetGroups }; | |||
| 179 | + | hasPendingReset = true; | |||
| 180 | + | conflicts = []; | |||
| 181 | + | resetAllDialogOpen = false; | |||
| 182 | + | showSuccess("Preview: all keybindings reset to defaults. Click Save to apply."); | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // Actually save the reset to backend | |||
| 186 | + | async function saveResetAll() { | |||
| 187 | + | if (!getWebKitBridge()) return; | |||
| 188 | + | ||||
| 189 | + | savingReset = true; | |||
| 190 | + | ||||
| 191 | + | setupCallback<void>("__dumber_keybindings_reset_all", () => { | |||
| 192 | + | hasPendingReset = false; | |||
| 193 | + | savingReset = false; | |||
| 194 | + | loadKeybindings(); | |||
| 195 | + | showSuccess("All keybindings saved successfully"); | |||
| 196 | + | }); | |||
| 197 | + | setupCallback<string>("__dumber_keybindings_reset_all_error", (msg) => { | |||
| 198 | + | error = typeof msg === "string" ? msg : "Failed to save keybindings"; | |||
| 199 | + | savingReset = false; | |||
| 200 | + | }); | |||
| 201 | + | ||||
| 202 | + | try { | |||
| 203 | + | postMessage({ | |||
| 204 | + | type: "reset_all_keybindings", | |||
| 205 | + | webviewId: getWebViewId(), | |||
| 206 | + | payload: {}, | |||
| 207 | + | }); | |||
| 208 | + | } catch (err) { | |||
| 209 | + | error = err instanceof Error ? err.message : "Failed to save keybindings"; | |||
| 210 | + | savingReset = false; | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // Cancel pending reset and reload from backend | |||
| 215 | + | function cancelReset() { | |||
| 216 | + | hasPendingReset = false; | |||
| 217 | + | loadKeybindings(); | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | onMount(() => { | |||
| 221 | + | loadKeybindings(); | |||
| 222 | + | }); | |||
| 223 | + | </script> | |||
| 224 | + | ||||
| 225 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 226 | + | <Card.Header> | |||
| 227 | + | <div class="flex items-center justify-between"> | |||
| 228 | + | <div> | |||
| 229 | + | <Card.Title>Keybindings</Card.Title> | |||
| 230 | + | <Card.Description> | |||
| 231 | + | Customize keyboard shortcuts for all modes. | |||
| 232 | + | </Card.Description> | |||
| 233 | + | </div> | |||
| 234 | + | <div class="flex gap-2"> | |||
| 235 | + | {#if hasPendingReset} | |||
| 236 | + | <Button variant="outline" size="sm" onclick={cancelReset} disabled={savingReset}> | |||
| 237 | + | Cancel | |||
| 238 | + | </Button> | |||
| 239 | + | <Button size="sm" onclick={saveResetAll} disabled={savingReset}> | |||
| 240 | + | {savingReset ? "Saving…" : "Save"} | |||
| 241 | + | </Button> | |||
| 242 | + | {:else} | |||
| 243 | + | <Button variant="outline" size="sm" onclick={loadKeybindings}> | |||
| 244 | + | <RefreshCw class="mr-1 size-4" /> | |||
| 245 | + | Refresh | |||
| 246 | + | </Button> | |||
| 247 | + | <AlertDialog.Root bind:open={resetAllDialogOpen}> | |||
| 248 | + | <AlertDialog.Trigger> | |||
| 249 | + | {#snippet child({ props })} | |||
| 250 | + | <Button variant="outline" size="sm" {...props}> | |||
| 251 | + | <RotateCcw class="mr-1 size-4" /> | |||
| 252 | + | Reset All | |||
| 253 | + | </Button> | |||
| 254 | + | {/snippet} | |||
| 255 | + | </AlertDialog.Trigger> | |||
| 256 | + | <AlertDialog.Content> | |||
| 257 | + | <AlertDialog.Header> | |||
| 258 | + | <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title> | |||
| 259 | + | <AlertDialog.Description> | |||
| 260 | + | This will preview all keybindings with their default values. You'll need to click Save to apply. | |||
| 261 | + | </AlertDialog.Description> | |||
| 262 | + | </AlertDialog.Header> | |||
| 263 | + | <AlertDialog.Footer> | |||
| 264 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |||
| 265 | + | <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action> | |||
| 266 | + | </AlertDialog.Footer> | |||
| 267 | + | </AlertDialog.Content> | |||
| 268 | + | </AlertDialog.Root> | |||
| 269 | + | {/if} | |||
| 270 | + | </div> | |||
| 271 | + | </div> | |||
| 272 | + | </Card.Header> | |||
| 273 | + | <Card.Content class="space-y-6"> | |||
| 274 | + | {#if hasPendingReset} | |||
| 275 | + | <div class="flex items-center gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"> | |||
| 276 | + | <AlertTriangle class="size-4" /> | |||
| 277 | + | <span>Unsaved changes: all keybindings previewed with defaults. Click <strong>Save</strong> to apply or <strong>Cancel</strong> to discard.</span> | |||
| 278 | + | </div> | |||
| 279 | + | {/if} | |||
| 280 | + | {#if loading} | |||
| 281 | + | <div class="flex items-center justify-center py-8"> | |||
| 282 | + | <Spinner class="size-6" /> | |||
| 283 | + | </div> | |||
| 284 | + | {:else if error} | |||
| 285 | + | <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |||
| 286 | + | <AlertTriangle class="size-4" /> | |||
| 287 | + | {error} | |||
| 288 | + | </div> | |||
| 289 | + | {/if} | |||
| 290 | + | {#if successMessage} | |||
| 291 | + | <div class="flex items-center gap-2 rounded-md border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-600 dark:text-green-400"> | |||
| 292 | + | <CheckCircle class="size-4" /> | |||
| 293 | + | {successMessage} | |||
| 294 | + | </div> | |||
| 295 | + | {/if} | |||
| 296 | + | {#if conflicts.length > 0} | |||
| 297 | + | <div class="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm"> | |||
| 298 | + | <div class="flex items-center gap-2 text-yellow-600 dark:text-yellow-400"> | |||
| 299 | + | <AlertTriangle class="size-4" /> | |||
| 300 | + | <span class="font-medium">Keybinding conflicts detected</span> | |||
| 301 | + | </div> | |||
| 302 | + | <ul class="mt-2 list-disc pl-6 text-muted-foreground"> | |||
| 303 | + | {#each conflicts as conflict (conflict.key + conflict.conflicting_action)} | |||
| 304 | + | <li> | |||
| 305 | + | <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to | |||
| 306 | + | <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode | |||
| 307 | + | </li> | |||
| 308 | + | {/each} | |||
| 309 | + | </ul> | |||
| 310 | + | <button | |||
| 311 | + | type="button" | |||
| 312 | + | class="mt-2 text-xs text-muted-foreground hover:text-foreground" | |||
| 313 | + | onclick={() => conflicts = []} | |||
| 314 | + | > | |||
| 315 | + | Dismiss | |||
| 316 | + | </button> | |||
| 317 | + | </div> | |||
| 318 | + | {/if} | |||
| 319 | + | {#if !loading && !error && keybindings} | |||
| 320 | + | {#each keybindings.groups as group (group.mode)} | |||
| 321 | + | <KeybindingGroup | |||
| 322 | + | {group} | |||
| 323 | + | onEdit={(action, entry) => startEdit(group.mode, action, entry)} | |||
| 324 | + | onReset={(action) => resetBinding(group.mode, action)} | |||
| 325 | + | /> | |||
| 326 | + | {/each} | |||
| 327 | + | {/if} | |||
| 328 | + | {#if !loading && !keybindings && !error} | |||
| 329 | + | <div class="text-center text-muted-foreground py-8">No keybindings available</div> | |||
| 330 | + | {/if} | |||
| 331 | + | </Card.Content> | |||
| 332 | + | </Card.Root> | |||
| 333 | + | ||||
| 334 | + | {#if editingBinding} | |||
| 335 | + | <KeyCaptureModal | |||
| 336 | + | binding={editingBinding.entry} | |||
| 337 | + | onSave={saveBinding} | |||
| 338 | + | onCancel={() => (editingBinding = null)} | |||
| 339 | + | /> | |||
| 340 | + | {/if} | |||
M webui/src/pages/homepage/analytics/HourlyActivityChart.svelte +1 -0
| @@ -19,6 +19,7 @@ | |||||
| 19 | 19 | ||||
| 20 | // Convert data to Frappe Charts format (ensure all 24 hours) | 20 | // Convert data to Frappe Charts format (ensure all 24 hours) | ||
| 21 | const chartData = $derived.by(() => { | 21 | const chartData = $derived.by(() => { | ||
| 22 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation | |||
| 22 | const visitsByHour = new Map<number, number>(); | 23 | const visitsByHour = new Map<number, number>(); | ||
| 23 | hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count)); | 24 | hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count)); | ||
| 24 | 25 | ||||
M webui/src/pages/homepage/favorites/FavoriteEditor.svelte +1 -1
| @@ -139,7 +139,7 @@ | |||||
| 139 | <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label> | 139 | <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label> | ||
| 140 | <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}> | 140 | <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}> | ||
| 141 | <option value={null}>— None —</option> | 141 | <option value={null}>— None —</option> | ||
| 142 | - | {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key} | 142 | + | {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key (key)} |
| 143 | <option value={key}>{key}</option> | 143 | <option value={key}>{key}</option> | ||
| 144 | {/each} | 144 | {/each} | ||
| 145 | </select> | 145 | </select> | ||
M webui/src/pages/homepage/messaging.ts +34 -27
| @@ -23,8 +23,12 @@ import { homepageState } from './state.svelte'; | |||||
| 23 | // REQUEST TRACKING | 23 | // REQUEST TRACKING | ||
| 24 | // ═══════════════════════════════════════════════════════════════════════════════ | 24 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 25 | 25 | ||||
| 26 | - | interface PendingRequest<T = unknown> { | 26 | + | // Pending requests store resolvers for different response types. |
| 27 | - | resolve: (value: T) => void; | 27 | + | // The resolve function accepts `unknown` since requests with different |
| 28 | + | // response types share the same map. Type safety is maintained at the | |||
| 29 | + | // sendMessage<T> API level. | |||
| 30 | + | interface PendingRequest { | |||
| 31 | + | resolve: (value: unknown) => void; | |||
| 28 | reject: (error: Error) => void; | 32 | reject: (error: Error) => void; | ||
| 29 | timeout: ReturnType<typeof setTimeout>; | 33 | timeout: ReturnType<typeof setTimeout>; | ||
| 30 | } | 34 | } | ||
| @@ -38,8 +42,8 @@ let callbacksInitialized = false; | |||||
| 38 | // WEBKIT BRIDGE UTILITIES | 42 | // WEBKIT BRIDGE UTILITIES | ||
| 39 | // ═══════════════════════════════════════════════════════════════════════════════ | 43 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 40 | 44 | ||||
| 41 | - | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | 45 | + | function getWebKitBridge(): DumberBridge | null { |
| 42 | - | const bridge = (window as any).webkit?.messageHandlers?.dumber; | 46 | + | const bridge = window.webkit?.messageHandlers?.dumber; |
| 43 | if (bridge && typeof bridge.postMessage === 'function') { | 47 | if (bridge && typeof bridge.postMessage === 'function') { | ||
| 44 | return bridge; | 48 | return bridge; | ||
| 45 | } | 49 | } | ||
| @@ -47,7 +51,7 @@ function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |||||
| 47 | } | 51 | } | ||
| 48 | 52 | ||||
| 49 | function getWebViewId(): number { | 53 | function getWebViewId(): number { | ||
| 50 | - | return (window as any).__dumber_webview_id || 0; | 54 | + | return window.__dumber_webview_id ?? 0; |
| 51 | } | 55 | } | ||
| 52 | 56 | ||||
| 53 | function generateRequestId(type: string): string { | 57 | function generateRequestId(type: string): string { | ||
| @@ -62,9 +66,7 @@ function initializeCallbacks(): void { | |||||
| 62 | if (callbacksInitialized) return; | 66 | if (callbacksInitialized) return; | ||
| 63 | 67 | ||||
| 64 | // Generic response handler - accepts a single Response object from Go backend | 68 | // Generic response handler - accepts a single Response object from Go backend | ||
| 65 | - | (window as any).__dumber_homepage_response = ( | 69 | + | window.__dumber_homepage_response = (response) => { |
| 66 | - | response: { requestId: string; success: boolean; data?: unknown; error?: string } | |||
| 67 | - | ) => { | |||
| 68 | const request = pendingRequests.get(response.requestId); | 70 | const request = pendingRequests.get(response.requestId); | ||
| 69 | if (!request) return; | 71 | if (!request) return; | ||
| 70 | 72 | ||||
| @@ -74,61 +76,60 @@ function initializeCallbacks(): void { | |||||
| 74 | if (response.success) { | 76 | if (response.success) { | ||
| 75 | request.resolve(response.data); | 77 | request.resolve(response.data); | ||
| 76 | } else { | 78 | } else { | ||
| 77 | - | request.reject(new Error(response.error || 'Unknown error')); | 79 | + | request.reject(new Error(response.error ?? 'Unknown error')); |
| 78 | } | 80 | } | ||
| 79 | }; | 81 | }; | ||
| 80 | 82 | ||||
| 81 | // Legacy callbacks for backward compatibility | 83 | // Legacy callbacks for backward compatibility | ||
| 82 | // These can receive either raw data or a Response object | 84 | // These can receive either raw data or a Response object | ||
| 83 | - | (window as any).__dumber_history_timeline = (dataOrResponse: unknown) => { | 85 | + | window.__dumber_history_timeline = (dataOrResponse) => { |
| 84 | handleLegacyOrResponseCallback(dataOrResponse); | 86 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 85 | }; | 87 | }; | ||
| 86 | 88 | ||||
| 87 | - | (window as any).__dumber_folders = (dataOrResponse: unknown) => { | 89 | + | window.__dumber_folders = (dataOrResponse) => { |
| 88 | handleLegacyOrResponseCallback(dataOrResponse); | 90 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 89 | }; | 91 | }; | ||
| 90 | 92 | ||||
| 91 | - | (window as any).__dumber_tags = (dataOrResponse: unknown) => { | 93 | + | window.__dumber_tags = (dataOrResponse) => { |
| 92 | handleLegacyOrResponseCallback(dataOrResponse); | 94 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 93 | }; | 95 | }; | ||
| 94 | 96 | ||||
| 95 | - | (window as any).__dumber_favorites = (dataOrResponse: unknown) => { | 97 | + | window.__dumber_favorites = (dataOrResponse) => { |
| 96 | handleLegacyOrResponseCallback(dataOrResponse); | 98 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 97 | }; | 99 | }; | ||
| 98 | 100 | ||||
| 99 | - | (window as any).__dumber_analytics = (dataOrResponse: unknown) => { | 101 | + | window.__dumber_analytics = (dataOrResponse) => { |
| 100 | handleLegacyOrResponseCallback(dataOrResponse); | 102 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 101 | }; | 103 | }; | ||
| 102 | 104 | ||||
| 103 | - | (window as any).__dumber_domain_stats = (dataOrResponse: unknown) => { | 105 | + | window.__dumber_domain_stats = (dataOrResponse) => { |
| 104 | handleLegacyOrResponseCallback(dataOrResponse); | 106 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 105 | }; | 107 | }; | ||
| 106 | 108 | ||||
| 107 | - | (window as any).__dumber_history_search_results = (dataOrResponse: unknown) => { | 109 | + | window.__dumber_history_search_results = (dataOrResponse) => { |
| 108 | handleLegacyOrResponseCallback(dataOrResponse); | 110 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 109 | }; | 111 | }; | ||
| 110 | 112 | ||||
| 111 | - | (window as any).__dumber_history_deleted = (dataOrResponse: unknown) => { | 113 | + | window.__dumber_history_deleted = (dataOrResponse) => { |
| 112 | handleLegacyOrResponseCallback(dataOrResponse); | 114 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 113 | }; | 115 | }; | ||
| 114 | 116 | ||||
| 115 | - | (window as any).__dumber_history_cleared = (dataOrResponse: unknown) => { | 117 | + | window.__dumber_history_cleared = (dataOrResponse) => { |
| 116 | handleLegacyOrResponseCallback(dataOrResponse); | 118 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 117 | }; | 119 | }; | ||
| 118 | 120 | ||||
| 119 | - | (window as any).__dumber_error = (errorOrResponse: unknown) => { | 121 | + | window.__dumber_error = (errorOrResponse) => { |
| 120 | // Handle both formats: direct error string or Response object with error | 122 | // Handle both formats: direct error string or Response object with error | ||
| 121 | - | const response = errorOrResponse as { requestId?: string; error?: string } | string; | 123 | + | if (typeof errorOrResponse === 'string') { |
| 122 | - | if (typeof response === 'string') { | |||
| 123 | // Legacy format: direct error string | 124 | // Legacy format: direct error string | ||
| 124 | - | console.error('[messaging] Legacy error:', response); | 125 | + | console.error('[messaging] Legacy error:', errorOrResponse); |
| 125 | - | } else if (response && typeof response === 'object') { | 126 | + | } else if (errorOrResponse && typeof errorOrResponse === 'object') { |
| 126 | // Response object format | 127 | // Response object format | ||
| 127 | - | const request = pendingRequests.get(response.requestId || 'default'); | 128 | + | const request = pendingRequests.get(errorOrResponse.requestId ?? 'default'); |
| 128 | if (request) { | 129 | if (request) { | ||
| 129 | clearTimeout(request.timeout); | 130 | clearTimeout(request.timeout); | ||
| 130 | - | pendingRequests.delete(response.requestId || 'default'); | 131 | + | pendingRequests.delete(errorOrResponse.requestId ?? 'default'); |
| 131 | - | request.reject(new Error(response.error || 'Unknown error')); | 132 | + | request.reject(new Error(errorOrResponse.error ?? 'Unknown error')); |
| 132 | } | 133 | } | ||
| 133 | } | 134 | } | ||
| 134 | }; | 135 | }; | ||
| @@ -188,7 +189,13 @@ async function sendMessage<T = unknown>( | |||||
| 188 | reject(new Error(`Request timeout: ${type}`)); | 189 | reject(new Error(`Request timeout: ${type}`)); | ||
| 189 | }, REQUEST_TIMEOUT_MS); | 190 | }, REQUEST_TIMEOUT_MS); | ||
| 190 | 191 | ||||
| 191 | - | pendingRequests.set(requestId, { resolve: resolve as any, reject, timeout }); | 192 | + | // Cast resolve to accept unknown - type safety is maintained at API level |
| 193 | + | // since sendMessage<T> returns Promise<T> and callers expect typed responses | |||
| 194 | + | pendingRequests.set(requestId, { | |||
| 195 | + | resolve: resolve as (value: unknown) => void, | |||
| 196 | + | reject, | |||
| 197 | + | timeout, | |||
| 198 | + | }); | |||
| 192 | 199 | ||||
| 193 | try { | 200 | try { | ||
| 194 | bridge.postMessage({ | 201 | bridge.postMessage({ | ||
M webui/src/pages/homepage/panels/HistoryPanel.svelte +2 -0
| @@ -37,6 +37,7 @@ | |||||
| 37 | }); | 37 | }); | ||
| 38 | 38 | ||||
| 39 | // Recompute timeline from filtered entries | 39 | // Recompute timeline from filtered entries | ||
| 40 | + | /* eslint-disable svelte/prefer-svelte-reactivity -- temporary Map/Date for data grouping, not reactive state */ | |||
| 40 | const displayedTimeline = $derived(() => { | 41 | const displayedTimeline = $derived(() => { | ||
| 41 | const entries = displayedEntries(); | 42 | const entries = displayedEntries(); | ||
| 42 | if (entries.length === 0) return []; | 43 | if (entries.length === 0) return []; | ||
| @@ -78,6 +79,7 @@ | |||||
| 78 | 79 | ||||
| 79 | return result.sort((a, b) => b.date.localeCompare(a.date)); | 80 | return result.sort((a, b) => b.date.localeCompare(a.date)); | ||
| 80 | }); | 81 | }); | ||
| 82 | + | /* eslint-enable svelte/prefer-svelte-reactivity */ | |||
| 81 | 83 | ||||
| 82 | const handleSelectEntry = (entry: HistoryEntry) => { | 84 | const handleSelectEntry = (entry: HistoryEntry) => { | ||
| 83 | navigateTo(entry.url); | 85 | navigateTo(entry.url); | ||
M webui/src/pages/homepage/shell/CommandPalette.svelte +1 -1
| @@ -165,7 +165,7 @@ | |||||
| 165 | 165 | ||||
| 166 | // Reset selection when query changes | 166 | // Reset selection when query changes | ||
| 167 | $effect(() => { | 167 | $effect(() => { | ||
| 168 | - | query; // Track query | 168 | + | void query; // Track query changes |
| 169 | selectedIndex = 0; | 169 | selectedIndex = 0; | ||
| 170 | }); | 170 | }); | ||
| 171 | 171 | ||||
M webui/src/pages/homepage/shell/HomepageShell.svelte +6 -9
| @@ -35,14 +35,12 @@ | |||||
| 35 | }; | 35 | }; | ||
| 36 | 36 | ||||
| 37 | function navigateWithViewTransition(url: string) { | 37 | function navigateWithViewTransition(url: string) { | ||
| 38 | - | const doc = document as any; | 38 | + | if (typeof document.startViewTransition !== 'function') { |
| 39 | - | ||||
| 40 | - | if (typeof doc?.startViewTransition !== 'function') { | |||
| 41 | window.location.href = url; | 39 | window.location.href = url; | ||
| 42 | return; | 40 | return; | ||
| 43 | } | 41 | } | ||
| 44 | 42 | ||||
| 45 | - | const transition = doc.startViewTransition(() => { | 43 | + | const transition = document.startViewTransition(() => { |
| 46 | document.documentElement.dataset.vt = 'leaving'; | 44 | document.documentElement.dataset.vt = 'leaving'; | ||
| 47 | }); | 45 | }); | ||
| 48 | 46 | ||||
| @@ -53,17 +51,15 @@ | |||||
| 53 | 51 | ||||
| 54 | const toggleTheme = () => { | 52 | const toggleTheme = () => { | ||
| 55 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | 53 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | ||
| 56 | - | const manager = (window as any).__dumber_color_scheme_manager as | 54 | + | const manager = window.__dumber_color_scheme_manager; |
| 57 | - | | { setUserPreference?: (theme: ThemeMode) => void } | |||
| 58 | - | | undefined; | |||
| 59 | 55 | ||||
| 60 | if (manager?.setUserPreference) { | 56 | if (manager?.setUserPreference) { | ||
| 61 | manager.setUserPreference(nextMode); | 57 | manager.setUserPreference(nextMode); | ||
| 62 | return; | 58 | return; | ||
| 63 | } | 59 | } | ||
| 64 | 60 | ||||
| 65 | - | if ((window as any).__dumber_setTheme) { | 61 | + | if (window.__dumber_setTheme) { |
| 66 | - | (window as any).__dumber_setTheme(nextMode); | 62 | + | window.__dumber_setTheme(nextMode); |
| 67 | localStorage.setItem('dumber.theme', nextMode); | 63 | localStorage.setItem('dumber.theme', nextMode); | ||
| 68 | return; | 64 | return; | ||
| 69 | } | 65 | } | ||
| @@ -109,6 +105,7 @@ | |||||
| 109 | <svelte:head> | 105 | <svelte:head> | ||
| 110 | <title>dumb://home</title> | 106 | <title>dumb://home</title> | ||
| 111 | <meta name="description" content="Dumber Browser - Homepage" /> | 107 | <meta name="description" content="Dumber Browser - Homepage" /> | ||
| 108 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization --> | |||
| 112 | {@html `<style> | 109 | {@html `<style> | ||
| 113 | html, body { margin: 0; padding: 0; } | 110 | html, body { margin: 0; padding: 0; } | ||
| 114 | html { background: var(--background, #0a0a0a); } | 111 | html { background: var(--background, #0a0a0a); } | ||
M webui/src/pages/homepage/shell/KeyboardHints.svelte +4 -4
| @@ -136,7 +136,7 @@ | |||||
| 136 | {#each navHints as hint (hint.description)} | 136 | {#each navHints as hint (hint.description)} | ||
| 137 | <div class="hint-row"> | 137 | <div class="hint-row"> | ||
| 138 | <div class="hint-keys"> | 138 | <div class="hint-keys"> | ||
| 139 | - | {#each hint.keys as key, i} | 139 | + | {#each hint.keys as key, i (key)} |
| 140 | {#if i > 0}<span class="key-sep">/</span>{/if} | 140 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 141 | <kbd>{key}</kbd> | 141 | <kbd>{key}</kbd> | ||
| 142 | {/each} | 142 | {/each} | ||
| @@ -157,7 +157,7 @@ | |||||
| 157 | {#each panelSwitchHints as hint (hint.description)} | 157 | {#each panelSwitchHints as hint (hint.description)} | ||
| 158 | <div class="hint-row"> | 158 | <div class="hint-row"> | ||
| 159 | <div class="hint-keys"> | 159 | <div class="hint-keys"> | ||
| 160 | - | {#each hint.keys as key, i} | 160 | + | {#each hint.keys as key, i (key)} |
| 161 | {#if i > 0}<span class="key-sep">/</span>{/if} | 161 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 162 | <kbd>{key}</kbd> | 162 | <kbd>{key}</kbd> | ||
| 163 | {/each} | 163 | {/each} | ||
| @@ -179,7 +179,7 @@ | |||||
| 179 | {#each panelHints as hint (hint.description)} | 179 | {#each panelHints as hint (hint.description)} | ||
| 180 | <div class="hint-row"> | 180 | <div class="hint-row"> | ||
| 181 | <div class="hint-keys"> | 181 | <div class="hint-keys"> | ||
| 182 | - | {#each hint.keys as key, i} | 182 | + | {#each hint.keys as key, i (key)} |
| 183 | {#if i > 0}<span class="key-sep">/</span>{/if} | 183 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 184 | <kbd>{key}</kbd> | 184 | <kbd>{key}</kbd> | ||
| 185 | {/each} | 185 | {/each} | ||
| @@ -201,7 +201,7 @@ | |||||
| 201 | {#each quickAccessHints as hint (hint.description)} | 201 | {#each quickAccessHints as hint (hint.description)} | ||
| 202 | <div class="hint-row"> | 202 | <div class="hint-row"> | ||
| 203 | <div class="hint-keys"> | 203 | <div class="hint-keys"> | ||
| 204 | - | {#each hint.keys as key, i} | 204 | + | {#each hint.keys as key, i (key)} |
| 205 | {#if i > 0}<span class="key-sep">/</span>{/if} | 205 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 206 | <kbd>{key}</kbd> | 206 | <kbd>{key}</kbd> | ||
| 207 | {/each} | 207 | {/each} | ||
M webui/src/pages/homepage/shell/StatusBar.svelte +2 -1
| @@ -38,7 +38,7 @@ | |||||
| 38 | label: 'entries', | 38 | label: 'entries', | ||
| 39 | filtered: false, | 39 | filtered: false, | ||
| 40 | }; | 40 | }; | ||
| 41 | - | case 'favorites': | 41 | + | case 'favorites': { |
| 42 | const filtered = homepageState.filteredFavorites; | 42 | const filtered = homepageState.filteredFavorites; | ||
| 43 | const total = homepageState.favorites.length; | 43 | const total = homepageState.favorites.length; | ||
| 44 | return { | 44 | return { | ||
| @@ -46,6 +46,7 @@ | |||||
| 46 | label: filtered.length === total ? 'items' : `of ${total}`, | 46 | label: filtered.length === total ? 'items' : `of ${total}`, | ||
| 47 | filtered: filtered.length !== total, | 47 | filtered: filtered.length !== total, | ||
| 48 | }; | 48 | }; | ||
| 49 | + | } | |||
| 49 | case 'analytics': | 50 | case 'analytics': | ||
| 50 | return { | 51 | return { | ||
| 51 | count: homepageState.domainStats.length, | 52 | count: homepageState.domainStats.length, | ||
M webui/src/pages/homepage/state.svelte.ts +2 -0
| @@ -71,6 +71,7 @@ let pendingKeyPrefix = $state<string | null>(null); | |||||
| 71 | // HELPER FUNCTIONS | 71 | // HELPER FUNCTIONS | ||
| 72 | // ═══════════════════════════════════════════════════════════════════════════════ | 72 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 73 | 73 | ||||
| 74 | + | /* eslint-disable svelte/prefer-svelte-reactivity -- helper function uses temporary Map/Date, not reactive state */ | |||
| 74 | function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | 75 | function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | ||
| 75 | const groups = new Map<string, HistoryEntry[]>(); | 76 | const groups = new Map<string, HistoryEntry[]>(); | ||
| 76 | const today = new Date(); | 77 | const today = new Date(); | ||
| @@ -109,6 +110,7 @@ function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | |||||
| 109 | 110 | ||||
| 110 | return result.sort((a, b) => b.date.localeCompare(a.date)); | 111 | return result.sort((a, b) => b.date.localeCompare(a.date)); | ||
| 111 | } | 112 | } | ||
| 113 | + | /* eslint-enable svelte/prefer-svelte-reactivity */ | |||
| 112 | 114 | ||||
| 113 | // ═══════════════════════════════════════════════════════════════════════════════ | 115 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 114 | // EXPORTED STORE | 116 | // EXPORTED STORE | ||
M webui/src/pages/homepage/types.ts +3 -1
| @@ -3,6 +3,8 @@ | |||||
| 3 | // Mirrors Go models from internal/db/models.go and related query files | 3 | // Mirrors Go models from internal/db/models.go and related query files | ||
| 4 | // ═══════════════════════════════════════════════════════════════════════════════ | 4 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 5 | 5 | ||||
| 6 | + | import type { Component } from 'svelte'; | |||
| 7 | + | ||||
| 6 | // ───────────────────────────────────────────────────────────────────────────── | 8 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 7 | // History Types | 9 | // History Types | ||
| 8 | // ───────────────────────────────────────────────────────────────────────────── | 10 | // ───────────────────────────────────────────────────────────────────────────── | ||
| @@ -110,7 +112,7 @@ export interface CommandPaletteItem { | |||||
| 110 | id: string; | 112 | id: string; | ||
| 111 | label: string; | 113 | label: string; | ||
| 112 | description?: string; | 114 | description?: string; | ||
| 113 | - | icon?: import('svelte').Component | string; | 115 | + | icon?: Component | string; |
| 114 | action: () => void; | 116 | action: () => void; | ||
| 115 | shortcut?: string; | 117 | shortcut?: string; | ||
| 116 | } | 118 | } | ||
A webui/src/types/dumber.d.ts +150 -0
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Global type declarations for Dumber browser WebKit bridge | |||
| 3 | + | */ | |||
| 4 | + | ||||
| 5 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 6 | + | // WEBKIT BRIDGE TYPES | |||
| 7 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 8 | + | ||||
| 9 | + | interface DumberBridge { | |||
| 10 | + | postMessage: (message: unknown) => void; | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | interface DumberWebKit { | |||
| 14 | + | messageHandlers?: { | |||
| 15 | + | dumber?: DumberBridge; | |||
| 16 | + | }; | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 20 | + | // VIEW TRANSITION API (not yet in TypeScript DOM lib) | |||
| 21 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 22 | + | ||||
| 23 | + | interface ViewTransition { | |||
| 24 | + | finished: Promise<void>; | |||
| 25 | + | ready: Promise<void>; | |||
| 26 | + | updateCallbackDone: Promise<void>; | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 30 | + | // THEME TYPES | |||
| 31 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 32 | + | ||||
| 33 | + | type ThemeMode = 'light' | 'dark'; | |||
| 34 | + | ||||
| 35 | + | interface ColorSchemeManager { | |||
| 36 | + | setUserPreference?: (theme: ThemeMode) => void; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 40 | + | // HOMEPAGE MESSAGING TYPES | |||
| 41 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 42 | + | ||||
| 43 | + | /** Response from Go backend for homepage requests */ | |||
| 44 | + | interface HomepageResponse { | |||
| 45 | + | requestId: string; | |||
| 46 | + | success: boolean; | |||
| 47 | + | data?: unknown; | |||
| 48 | + | error?: string; | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | /** Error response format (can be string for legacy or object) */ | |||
| 52 | + | type HomepageErrorResponse = string | { requestId?: string; error?: string }; | |||
| 53 | + | ||||
| 54 | + | /** Callback type for homepage responses */ | |||
| 55 | + | type HomepageResponseCallback = (response: HomepageResponse) => void; | |||
| 56 | + | ||||
| 57 | + | /** Callback type for legacy data responses (can receive raw data or Response object) */ | |||
| 58 | + | type HomepageLegacyCallback = (dataOrResponse: unknown) => void; | |||
| 59 | + | ||||
| 60 | + | /** Callback type for error responses */ | |||
| 61 | + | type HomepageErrorCallback = (errorOrResponse: HomepageErrorResponse) => void; | |||
| 62 | + | ||||
| 63 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 64 | + | // GLOBAL DECLARATIONS | |||
| 65 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 66 | + | ||||
| 67 | + | declare global { | |||
| 68 | + | // Re-export bridge type for use in other modules | |||
| 69 | + | type DumberBridge = { | |||
| 70 | + | postMessage: (message: unknown) => void; | |||
| 71 | + | }; | |||
| 72 | + | interface Window { | |||
| 73 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 74 | + | // Core WebKit Bridge | |||
| 75 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 76 | + | /** WebView ID assigned by the browser backend */ | |||
| 77 | + | __dumber_webview_id?: number; | |||
| 78 | + | /** WebKit message handlers for native bridge */ | |||
| 79 | + | webkit?: DumberWebKit; | |||
| 80 | + | ||||
| 81 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 82 | + | // Config Page Callbacks | |||
| 83 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 84 | + | /** Callback invoked when config save succeeds */ | |||
| 85 | + | __dumber_config_saved?: (resp?: unknown) => void; | |||
| 86 | + | /** Callback invoked when config save fails */ | |||
| 87 | + | __dumber_config_error?: (msg: unknown) => void; | |||
| 88 | + | ||||
| 89 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 90 | + | // Keybindings Callbacks | |||
| 91 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 92 | + | /** Callback invoked when keybindings are loaded */ | |||
| 93 | + | __dumber_keybindings_loaded?: (resp?: unknown) => void; | |||
| 94 | + | /** Callback invoked when keybindings load fails */ | |||
| 95 | + | __dumber_keybindings_error?: (msg: unknown) => void; | |||
| 96 | + | /** Callback invoked when a keybinding is set */ | |||
| 97 | + | __dumber_keybinding_set?: (resp?: unknown) => void; | |||
| 98 | + | /** Callback invoked when keybinding set fails */ | |||
| 99 | + | __dumber_keybinding_set_error?: (msg: unknown) => void; | |||
| 100 | + | /** Callback invoked when a keybinding is reset */ | |||
| 101 | + | __dumber_keybinding_reset?: (resp?: unknown) => void; | |||
| 102 | + | /** Callback invoked when keybinding reset fails */ | |||
| 103 | + | __dumber_keybinding_reset_error?: (msg: unknown) => void; | |||
| 104 | + | /** Callback invoked when all keybindings are reset */ | |||
| 105 | + | __dumber_keybindings_reset_all?: (resp?: unknown) => void; | |||
| 106 | + | /** Callback invoked when reset all keybindings fails */ | |||
| 107 | + | __dumber_keybindings_reset_all_error?: (msg: unknown) => void; | |||
| 108 | + | ||||
| 109 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 110 | + | // Homepage Callbacks | |||
| 111 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 112 | + | /** Generic response handler for homepage requests */ | |||
| 113 | + | __dumber_homepage_response?: HomepageResponseCallback; | |||
| 114 | + | /** History timeline data callback */ | |||
| 115 | + | __dumber_history_timeline?: HomepageLegacyCallback; | |||
| 116 | + | /** Folders data callback */ | |||
| 117 | + | __dumber_folders?: HomepageLegacyCallback; | |||
| 118 | + | /** Tags data callback */ | |||
| 119 | + | __dumber_tags?: HomepageLegacyCallback; | |||
| 120 | + | /** Favorites data callback */ | |||
| 121 | + | __dumber_favorites?: HomepageLegacyCallback; | |||
| 122 | + | /** Analytics data callback */ | |||
| 123 | + | __dumber_analytics?: HomepageLegacyCallback; | |||
| 124 | + | /** Domain stats data callback */ | |||
| 125 | + | __dumber_domain_stats?: HomepageLegacyCallback; | |||
| 126 | + | /** History search results callback */ | |||
| 127 | + | __dumber_history_search_results?: HomepageLegacyCallback; | |||
| 128 | + | /** History deleted callback */ | |||
| 129 | + | __dumber_history_deleted?: HomepageLegacyCallback; | |||
| 130 | + | /** History cleared callback */ | |||
| 131 | + | __dumber_history_cleared?: HomepageLegacyCallback; | |||
| 132 | + | /** Error callback for homepage requests */ | |||
| 133 | + | __dumber_error?: HomepageErrorCallback; | |||
| 134 | + | ||||
| 135 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 136 | + | // Theme Management | |||
| 137 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 138 | + | /** Color scheme manager for theme switching */ | |||
| 139 | + | __dumber_color_scheme_manager?: ColorSchemeManager; | |||
| 140 | + | /** Legacy theme setter */ | |||
| 141 | + | __dumber_setTheme?: (theme: ThemeMode) => void; | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | interface Document { | |||
| 145 | + | /** View Transition API */ | |||
| 146 | + | startViewTransition?: (callback: () => void | Promise<void>) => ViewTransition; | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | export {}; | |||