Merge pull request #143 from bnema/next
Release v0.26.0
76 files · +6666 -616
M .mockery.yaml +4 -0
| @@ -10,6 +10,7 @@ packages: | |||
| 10 | 10 | interfaces: | |
| 11 | 11 | ConfigMigrator: {} | |
| 12 | 12 | ConfigSchemaProvider: {} | |
| 13 | + | ConfigTransformer: {} | |
| 13 | 14 | DatabaseProvider: {} | |
| 14 | 15 | DiffFormatter: {} | |
| 15 | 16 | SessionLogger: {} | |
| @@ -27,6 +28,9 @@ packages: | |||
| 27 | 28 | FocusedInputProvider: {} | |
| 28 | 29 | AccentPickerUI: {} | |
| 29 | 30 | FontDetector: {} | |
| 31 | + | KeybindingsProvider: {} | |
| 32 | + | KeybindingsSaver: {} | |
| 33 | + | DownloadResponse: {} | |
| 30 | 34 | github.com/bnema/dumber/internal/domain/repository: | |
| 31 | 35 | config: | |
| 32 | 36 | dir: '{{.InterfaceDir}}/mocks' | |
M CHANGELOG.md +30 -1
| @@ -2,12 +2,41 @@ | |||
| 2 | 2 | ||
| 3 | 3 | All notable changes to this project will be documented in this file. | |
| 4 | 4 | ||
| 5 | - | ## [Unreleased] | |
| 5 | + | ## [0.26.0] - 2026-01-24 | |
| 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 | + | - **Inline ghost text autocompletion**: Omnibox now shows inline ghost text suggestions from history as you type. Accept with Right Arrow (full) or Ctrl+Right (word). Properly aligned with entry text. | |
| 12 | + | - **Omnibox click-outside-to-close**: Omnibox automatically closes when clicking outside its bounds. | |
| 13 | + | - **GTK/WebKit log capture**: New `logging.capture_gtk_webkit_logs` config option to capture GTK and WebKit logs for debugging. | |
| 14 | + | - **Startup performance tracing**: Added startup trace logging for cold start performance analysis and optimization. | |
| 15 | + | ||
| 16 | + | ### Changed | |
| 17 | + | - **Simplified keybinding syntax**: Keybindings now use symbol syntax (`[`, `]`, `{`, `}`) instead of verbose GTK names (`bracketleft`, `braceleft`). Fixes #122. | |
| 18 | + | - **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. | |
| 19 | + | - **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. | |
| 20 | + | - **Deferred startup initialization**: Non-critical initialization (WebView pool prewarm, etc.) now deferred until after first navigation starts for faster cold start. | |
| 21 | + | - **Omnibox search ranking**: Improved domain matching boost and FTS5 period handling for better search result relevance. | |
| 22 | + | ||
| 23 | + | ### Upgrade Notes | |
| 24 | + | > **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. | |
| 6 | 25 | ||
| 7 | 26 | ### Fixed | |
| 27 | + | - **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. | |
| 28 | + | - **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 | 29 | - **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 | 30 | - **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 | 31 | - **Noisy gesture navigation logs**: Mouse button back/forward navigation errors (expected when no history) now log at debug level instead of error level. | |
| 32 | + | - **Omnibox ghost text race conditions**: Fixed stale ghost text appearing due to race conditions. Query now passed through the entire autocomplete chain. | |
| 33 | + | - **Omnibox ghost text clipping**: Ghost text now properly clips to entry bounds and aligns vertically with input text. | |
| 34 | + | - **Ghost text on history scroll**: Disabled ghost text suggestions when scrolling through history with arrow keys after user has typed. | |
| 35 | + | - **Popup WebView hierarchy**: Fixed popup WebViews not being inserted into GTK hierarchy before create signal returns. | |
| 36 | + | - **OAuth popup timeout**: Added 30-second safety timeout for OAuth popup handling to prevent stuck popups. | |
| 37 | + | - **External page background**: Reset WebView background to white for external pages to prevent dark theme color bleeding into light-themed sites. | |
| 38 | + | - **Localhost URL normalization**: Fixed localhost URL handling and improved download handler architecture for cleaner code. | |
| 39 | + | - **Svelte 5 Proxy serialization**: Fixed keybinding bridge serialization to handle Svelte 5 Proxies and added conflict detection. | |
| 11 | 40 | ||
| 12 | 41 | ## [0.25.0] - 2026-01-08 | |
| 13 | 42 | ||
M docs/CONFIG.md +29 -13
| @@ -139,6 +139,7 @@ auto_open_on_new_pane = false | |||
| 139 | 139 | | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory | | |
| 140 | 140 | | `logging.enable_file_log` | bool | `true` | - | Enable file logging | | |
| 141 | 141 | | `logging.capture_console` | bool | `false` | - | Capture browser console to logs | | |
| 142 | + | | `logging.capture_gtk_webkit_logs` | bool | `false` | - | Capture GTK and WebKit logs for debugging | | |
| 142 | 143 | ||
| 143 | 144 | ## Appearance | |
| 144 | 145 | ||
| @@ -236,10 +237,10 @@ move-pane-to-tab = ["m"] | |||
| 236 | 237 | move-pane-to-next-tab = ["M", "shift+m"] | |
| 237 | 238 | ||
| 238 | 239 | # Consume-or-expel (niri-style) - very alpha | |
| 239 | - | consume-or-expel-left = ["bracketleft", "["] | |
| 240 | - | consume-or-expel-right = ["bracketright", "]"] | |
| 241 | - | consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"] | |
| 242 | - | consume-or-expel-down = ["shift+bracketright", "braceright", "}"] | |
| 240 | + | consume-or-expel-left = ["["] | |
| 241 | + | consume-or-expel-right = ["]"] | |
| 242 | + | consume-or-expel-up = ["{"] | |
| 243 | + | consume-or-expel-down = ["}"] | |
| 243 | 244 | ||
| 244 | 245 | focus-right = ["shift+arrowright", "shift+l"] | |
| 245 | 246 | focus-left = ["shift+arrowleft", "shift+h"] | |
| @@ -307,16 +308,31 @@ Notes: | |||
| 307 | 308 | ||
| 308 | 309 | ### Global Shortcuts | |
| 309 | 310 | ||
| 310 | - | | Key | Type | Default | Description | | |
| 311 | - | |-----|------|---------|-------------| | |
| 312 | - | | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) | | |
| 313 | - | | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | |
| 314 | - | | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | |
| 315 | - | | `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) | | |
| 316 | - | | `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) | | |
| 317 | - | | `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) | | |
| 318 | - | | `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) | | |
| 311 | + | Global shortcuts are configured under `workspace.shortcuts.actions` using the same `ActionBinding` structure as modal keybindings: | |
| 312 | + | ||
| 313 | + | | Action | Default Keys | Description | | |
| 314 | + | |--------|--------------|-------------| | |
| 315 | + | | `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) | | |
| 316 | + | | `next_tab` | `ctrl+tab` | Switch to next tab | | |
| 317 | + | | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab | | |
| 318 | + | | `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked | | |
| 319 | + | | `consume_or_expel_right` | `alt+]` | Consume into right sibling stack, or expel right if stacked | | |
| 320 | + | | `consume_or_expel_up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked | | |
| 321 | + | | `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked | | |
| 319 | 322 | ||
| 323 | + | **Example:** | |
| 324 | + | ```toml | |
| 325 | + | [workspace.shortcuts.actions.close_pane] | |
| 326 | + | keys = ["ctrl+w"] | |
| 327 | + | desc = "Close active pane" | |
| 328 | + | ||
| 329 | + | [workspace.shortcuts.actions.next_tab] | |
| 330 | + | keys = ["ctrl+tab", "alt+l"] | |
| 331 | + | desc = "Switch to next tab" | |
| 332 | + | ``` | |
| 333 | + | ||
| 334 | + | > **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually. | |
| 335 | + | > | |
| 320 | 336 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | |
| 321 | 337 | > | |
| 322 | 338 | > **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 | + | } | |
M internal/application/port/download.go +8 -0
| @@ -26,3 +26,11 @@ type DownloadEvent struct { | |||
| 26 | 26 | type DownloadEventHandler interface { | |
| 27 | 27 | OnDownloadEvent(ctx context.Context, event DownloadEvent) | |
| 28 | 28 | } | |
| 29 | + | ||
| 30 | + | // DownloadResponse provides access to response metadata for downloads. | |
| 31 | + | // This abstracts the WebKit URIResponse to allow testing without CGO. | |
| 32 | + | type DownloadResponse interface { | |
| 33 | + | GetMimeType() string | |
| 34 | + | GetSuggestedFilename() string | |
| 35 | + | GetUri() string | |
| 36 | + | } | |
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 | 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 | 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 | 1108 | // The first argument is typically a *testing.T value. | |
| 1042 | 1109 | func NewMockDatabaseProvider(t interface { | |
| @@ -1495,6 +1562,425 @@ func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Co | |||
| 1495 | 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 | 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 | 1985 | // The first argument is typically a *testing.T value. | |
| 1500 | 1986 | func NewMockSessionLogger(t interface { | |
A internal/application/usecase/autocomplete.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/autocomplete" | |
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // AutocompleteUseCase handles inline autocomplete suggestions. | |
| 11 | + | type AutocompleteUseCase struct { | |
| 12 | + | historyUC *SearchHistoryUseCase | |
| 13 | + | favoritesUC *ManageFavoritesUseCase | |
| 14 | + | shortcutsUC *SearchShortcutsUseCase | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewAutocompleteUseCase creates a new autocomplete use case. | |
| 18 | + | func NewAutocompleteUseCase( | |
| 19 | + | historyUC *SearchHistoryUseCase, | |
| 20 | + | favoritesUC *ManageFavoritesUseCase, | |
| 21 | + | shortcutsUC *SearchShortcutsUseCase, | |
| 22 | + | ) *AutocompleteUseCase { | |
| 23 | + | return &AutocompleteUseCase{ | |
| 24 | + | historyUC: historyUC, | |
| 25 | + | favoritesUC: favoritesUC, | |
| 26 | + | shortcutsUC: shortcutsUC, | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // GetSuggestionInput contains parameters for getting an autocomplete suggestion. | |
| 31 | + | type GetSuggestionInput struct { | |
| 32 | + | Input string // Current user input | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // GetSuggestionOutput contains the best autocomplete suggestion. | |
| 36 | + | type GetSuggestionOutput struct { | |
| 37 | + | Suggestion *autocomplete.Suggestion | |
| 38 | + | Found bool | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // GetSuggestion returns the best autocomplete suggestion for the given input. | |
| 42 | + | // Priority: Favorites > History (by frecency) > Bang shortcuts. | |
| 43 | + | func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput { | |
| 44 | + | log := logging.FromContext(ctx) | |
| 45 | + | ||
| 46 | + | if input.Input == "" { | |
| 47 | + | return &GetSuggestionOutput{Found: false} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Try favorites first (highest priority) | |
| 51 | + | if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil { | |
| 52 | + | log.Debug(). | |
| 53 | + | Str("input", input.Input). | |
| 54 | + | Str("match", suggestion.FullText). | |
| 55 | + | Msg("autocomplete: matched favorite") | |
| 56 | + | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Try history (second priority) | |
| 60 | + | if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil { | |
| 61 | + | log.Debug(). | |
| 62 | + | Str("input", input.Input). | |
| 63 | + | Str("match", suggestion.FullText). | |
| 64 | + | Msg("autocomplete: matched history") | |
| 65 | + | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Try bang shortcuts (lowest priority for URL-style completion) | |
| 69 | + | if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil { | |
| 70 | + | log.Debug(). | |
| 71 | + | Str("input", input.Input). | |
| 72 | + | Str("match", suggestion.FullText). | |
| 73 | + | Msg("autocomplete: matched bang shortcut") | |
| 74 | + | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | return &GetSuggestionOutput{Found: false} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // findFavoriteSuggestion finds the best matching favorite for the input. | |
| 81 | + | func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 82 | + | if uc.favoritesUC == nil { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | favorites, err := uc.favoritesUC.GetAll(ctx) | |
| 87 | + | if err != nil { | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | for _, fav := range favorites { | |
| 92 | + | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL) | |
| 93 | + | if ok { | |
| 94 | + | return &autocomplete.Suggestion{ | |
| 95 | + | FullText: matchedURL, | |
| 96 | + | Suffix: suffix, | |
| 97 | + | Source: autocomplete.SourceFavorite, | |
| 98 | + | Title: fav.Title, | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // findHistorySuggestion finds the best matching history entry for the input. | |
| 107 | + | func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 108 | + | if uc.historyUC == nil { | |
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Search history for matches | |
| 113 | + | output, err := uc.historyUC.Search(ctx, SearchInput{ | |
| 114 | + | Query: input, | |
| 115 | + | Limit: 10, // Get top 10 to find best prefix match | |
| 116 | + | }) | |
| 117 | + | if err != nil || output == nil { | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Find the first result that is a prefix match | |
| 122 | + | for _, match := range output.Matches { | |
| 123 | + | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, match.Entry.URL) | |
| 124 | + | if ok { | |
| 125 | + | return &autocomplete.Suggestion{ | |
| 126 | + | FullText: matchedURL, | |
| 127 | + | Suffix: suffix, | |
| 128 | + | Source: autocomplete.SourceHistory, | |
| 129 | + | Title: match.Entry.Title, | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // findBangSuggestion finds the best matching bang shortcut for the input. | |
| 138 | + | func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 139 | + | if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' { | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Get filtered bang suggestions | |
| 144 | + | output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input}) | |
| 145 | + | if len(output.Suggestions) == 0 { | |
| 146 | + | return nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Use the first (best) match | |
| 150 | + | bang := output.Suggestions[0] | |
| 151 | + | fullBang := "!" + bang.Key | |
| 152 | + | ||
| 153 | + | suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang) | |
| 154 | + | if ok { | |
| 155 | + | return &autocomplete.Suggestion{ | |
| 156 | + | FullText: fullBang, | |
| 157 | + | Suffix: suffix, | |
| 158 | + | Source: autocomplete.SourceBangShortcut, | |
| 159 | + | Title: bang.Description, | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | return nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // GetSuggestionForURL returns an autocomplete suggestion specifically for a URL. | |
| 167 | + | // This is used when the user selects a row from the list to update ghost text. | |
| 168 | + | func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput { | |
| 169 | + | if input == "" || targetURL == "" { | |
| 170 | + | return &GetSuggestionOutput{Found: false} | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL) | |
| 174 | + | if !ok { | |
| 175 | + | return &GetSuggestionOutput{Found: false} | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | return &GetSuggestionOutput{ | |
| 179 | + | Suggestion: &autocomplete.Suggestion{ | |
| 180 | + | FullText: matchedURL, | |
| 181 | + | Suffix: suffix, | |
| 182 | + | Source: autocomplete.SourceHistory, // Default to history for selected rows | |
| 183 | + | }, | |
| 184 | + | Found: true, | |
| 185 | + | } | |
| 186 | + | } | |
A internal/application/usecase/autocomplete_test.go +283 -0
| @@ -0,0 +1,283 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/autocomplete" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestAutocompleteUseCase_GetSuggestion_EmptyInput(t *testing.T) { | |
| 16 | + | ctx := testContext() | |
| 17 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 18 | + | ||
| 19 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: ""}) | |
| 20 | + | ||
| 21 | + | require.NotNil(t, output) | |
| 22 | + | assert.False(t, output.Found) | |
| 23 | + | assert.Nil(t, output.Suggestion) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAutocompleteUseCase_GetSuggestion_NilDependencies(t *testing.T) { | |
| 27 | + | ctx := testContext() | |
| 28 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 29 | + | ||
| 30 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |
| 31 | + | ||
| 32 | + | require.NotNil(t, output) | |
| 33 | + | assert.False(t, output.Found) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) { | |
| 37 | + | ctx := testContext() | |
| 38 | + | ||
| 39 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 40 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 41 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 42 | + | ||
| 43 | + | favorites := []*entity.Favorite{ | |
| 44 | + | {ID: 1, URL: "https://github.com/user/repo", Title: "My Repo"}, | |
| 45 | + | {ID: 2, URL: "https://google.com", Title: "Google"}, | |
| 46 | + | } | |
| 47 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |
| 48 | + | ||
| 49 | + | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 50 | + | uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil) | |
| 51 | + | ||
| 52 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |
| 53 | + | ||
| 54 | + | require.NotNil(t, output) | |
| 55 | + | assert.True(t, output.Found) | |
| 56 | + | assert.NotNil(t, output.Suggestion) | |
| 57 | + | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | |
| 58 | + | assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText) | |
| 59 | + | assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix) | |
| 60 | + | assert.Equal(t, "My Repo", output.Suggestion.Title) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) { | |
| 64 | + | ctx := testContext() | |
| 65 | + | ||
| 66 | + | // Favorites returns empty | |
| 67 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 68 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 69 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 70 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | |
| 71 | + | ||
| 72 | + | // History returns a match | |
| 73 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 74 | + | historyMatches := []entity.HistoryMatch{ | |
| 75 | + | {Entry: &entity.HistoryEntry{URL: "https://news.ycombinator.com", Title: "Hacker News"}}, | |
| 76 | + | } | |
| 77 | + | historyRepo.EXPECT().Search(mock.Anything, "news", 10).Return(historyMatches, nil) | |
| 78 | + | ||
| 79 | + | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 80 | + | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 81 | + | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |
| 82 | + | ||
| 83 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"}) | |
| 84 | + | ||
| 85 | + | require.NotNil(t, output) | |
| 86 | + | assert.True(t, output.Found) | |
| 87 | + | assert.NotNil(t, output.Suggestion) | |
| 88 | + | assert.Equal(t, autocomplete.SourceHistory, output.Suggestion.Source) | |
| 89 | + | assert.Equal(t, "news.ycombinator.com", output.Suggestion.FullText) | |
| 90 | + | assert.Equal(t, ".ycombinator.com", output.Suggestion.Suffix) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestAutocompleteUseCase_GetSuggestion_BangShortcut(t *testing.T) { | |
| 94 | + | ctx := testContext() | |
| 95 | + | ||
| 96 | + | shortcuts := map[string]usecase.SearchShortcut{ | |
| 97 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 98 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}, | |
| 99 | + | } | |
| 100 | + | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |
| 101 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC) | |
| 102 | + | ||
| 103 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "!g"}) | |
| 104 | + | ||
| 105 | + | require.NotNil(t, output) | |
| 106 | + | assert.True(t, output.Found) | |
| 107 | + | assert.NotNil(t, output.Suggestion) | |
| 108 | + | assert.Equal(t, autocomplete.SourceBangShortcut, output.Suggestion.Source) | |
| 109 | + | assert.Equal(t, "!gh", output.Suggestion.FullText) | |
| 110 | + | assert.Equal(t, "h", output.Suggestion.Suffix) | |
| 111 | + | assert.Equal(t, "GitHub search", output.Suggestion.Title) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestAutocompleteUseCase_GetSuggestion_BangWithoutPrefix(t *testing.T) { | |
| 115 | + | ctx := testContext() | |
| 116 | + | ||
| 117 | + | shortcuts := map[string]usecase.SearchShortcut{ | |
| 118 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 119 | + | } | |
| 120 | + | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |
| 121 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC) | |
| 122 | + | ||
| 123 | + | // Input without ! should not match bang shortcuts | |
| 124 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "gh"}) | |
| 125 | + | ||
| 126 | + | require.NotNil(t, output) | |
| 127 | + | assert.False(t, output.Found) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) { | |
| 131 | + | ctx := testContext() | |
| 132 | + | ||
| 133 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 134 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 135 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 136 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | |
| 137 | + | ||
| 138 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 139 | + | historyRepo.EXPECT().Search(mock.Anything, "xyz123", 10).Return([]entity.HistoryMatch{}, nil) | |
| 140 | + | ||
| 141 | + | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 142 | + | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 143 | + | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |
| 144 | + | ||
| 145 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "xyz123"}) | |
| 146 | + | ||
| 147 | + | require.NotNil(t, output) | |
| 148 | + | assert.False(t, output.Found) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) { | |
| 152 | + | ctx := testContext() | |
| 153 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 154 | + | ||
| 155 | + | tests := []struct { | |
| 156 | + | name string | |
| 157 | + | input string | |
| 158 | + | targetURL string | |
| 159 | + | }{ | |
| 160 | + | {name: "empty input", input: "", targetURL: "https://example.com"}, | |
| 161 | + | {name: "empty URL", input: "exa", targetURL: ""}, | |
| 162 | + | {name: "both empty", input: "", targetURL: ""}, | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | for _, tt := range tests { | |
| 166 | + | t.Run(tt.name, func(t *testing.T) { | |
| 167 | + | output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL) | |
| 168 | + | require.NotNil(t, output) | |
| 169 | + | assert.False(t, output.Found) | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) { | |
| 175 | + | ctx := testContext() | |
| 176 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 177 | + | ||
| 178 | + | tests := []struct { | |
| 179 | + | name string | |
| 180 | + | input string | |
| 181 | + | targetURL string | |
| 182 | + | wantSuffix string | |
| 183 | + | wantMatchedURL string | |
| 184 | + | }{ | |
| 185 | + | { | |
| 186 | + | name: "prefix match without protocol", | |
| 187 | + | input: "github", | |
| 188 | + | targetURL: "https://github.com/user/repo", | |
| 189 | + | wantSuffix: ".com/user/repo", | |
| 190 | + | wantMatchedURL: "github.com/user/repo", | |
| 191 | + | }, | |
| 192 | + | { | |
| 193 | + | name: "prefix match with protocol", | |
| 194 | + | input: "https://github", | |
| 195 | + | targetURL: "https://github.com/user/repo", | |
| 196 | + | wantSuffix: ".com/user/repo", | |
| 197 | + | wantMatchedURL: "https://github.com/user/repo", | |
| 198 | + | }, | |
| 199 | + | { | |
| 200 | + | name: "case insensitive match", | |
| 201 | + | input: "GITHUB", | |
| 202 | + | targetURL: "https://github.com", | |
| 203 | + | wantSuffix: ".com", | |
| 204 | + | wantMatchedURL: "github.com", | |
| 205 | + | }, | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | for _, tt := range tests { | |
| 209 | + | t.Run(tt.name, func(t *testing.T) { | |
| 210 | + | output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL) | |
| 211 | + | require.NotNil(t, output) | |
| 212 | + | assert.True(t, output.Found) | |
| 213 | + | require.NotNil(t, output.Suggestion) | |
| 214 | + | assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix) | |
| 215 | + | assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText) | |
| 216 | + | }) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) { | |
| 221 | + | ctx := testContext() | |
| 222 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 223 | + | ||
| 224 | + | output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com") | |
| 225 | + | ||
| 226 | + | require.NotNil(t, output) | |
| 227 | + | assert.False(t, output.Found) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) { | |
| 231 | + | ctx := testContext() | |
| 232 | + | ||
| 233 | + | // Both favorites and history have matches - favorites should win | |
| 234 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 235 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 236 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 237 | + | favorites := []*entity.Favorite{ | |
| 238 | + | {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"}, | |
| 239 | + | } | |
| 240 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |
| 241 | + | ||
| 242 | + | // History would also match, but should not be called because favorites matched | |
| 243 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 244 | + | // Note: no EXPECT on historyRepo - it shouldn't be called | |
| 245 | + | ||
| 246 | + | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 247 | + | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 248 | + | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |
| 249 | + | ||
| 250 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |
| 251 | + | ||
| 252 | + | require.NotNil(t, output) | |
| 253 | + | assert.True(t, output.Found) | |
| 254 | + | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | |
| 255 | + | assert.Equal(t, "Favorite Repo", output.Suggestion.Title) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) { | |
| 259 | + | ctx := testContext() | |
| 260 | + | ||
| 261 | + | // Favorites returns empty | |
| 262 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 263 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 264 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 265 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | |
| 266 | + | ||
| 267 | + | // History returns results but none are prefix matches | |
| 268 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 269 | + | historyMatches := []entity.HistoryMatch{ | |
| 270 | + | // URL doesn't start with "git", so no prefix match | |
| 271 | + | {Entry: &entity.HistoryEntry{URL: "https://example.com/git", Title: "Example"}}, | |
| 272 | + | } | |
| 273 | + | historyRepo.EXPECT().Search(mock.Anything, "git", 10).Return(historyMatches, nil) | |
| 274 | + | ||
| 275 | + | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 276 | + | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 277 | + | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |
| 278 | + | ||
| 279 | + | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"}) | |
| 280 | + | ||
| 281 | + | require.NotNil(t, output) | |
| 282 | + | assert.False(t, output.Found) | |
| 283 | + | } | |
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/prepare_download.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/download" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // PrepareDownloadInput contains the inputs for preparing a download destination. | |
| 13 | + | type PrepareDownloadInput struct { | |
| 14 | + | // SuggestedFilename is the filename suggested by WebKit (from Content-Disposition or other sources). | |
| 15 | + | SuggestedFilename string | |
| 16 | + | // Response provides additional metadata (MIME type, URI) for filename resolution. | |
| 17 | + | // May be nil if not available. | |
| 18 | + | Response port.DownloadResponse | |
| 19 | + | // DownloadDir is the directory where downloads should be saved. | |
| 20 | + | DownloadDir string | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // PrepareDownloadOutput contains the resolved download destination. | |
| 24 | + | type PrepareDownloadOutput struct { | |
| 25 | + | // Filename is the sanitized, safe filename to use. | |
| 26 | + | Filename string | |
| 27 | + | // DestinationPath is the full path where the download should be saved. | |
| 28 | + | DestinationPath string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // PrepareDownloadUseCase orchestrates filename resolution for downloads. | |
| 32 | + | // It combines multiple sources (suggested name, response headers, URI) to | |
| 33 | + | // determine the best filename, then sanitizes it to prevent path traversal. | |
| 34 | + | type PrepareDownloadUseCase struct { | |
| 35 | + | fs port.FileSystem | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // NewPrepareDownloadUseCase creates a new PrepareDownloadUseCase. | |
| 39 | + | // If fs is nil, filename deduplication is disabled. | |
| 40 | + | func NewPrepareDownloadUseCase(fs port.FileSystem) *PrepareDownloadUseCase { | |
| 41 | + | return &PrepareDownloadUseCase{fs: fs} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Execute resolves the download filename and destination path. | |
| 45 | + | func (u *PrepareDownloadUseCase) Execute(ctx context.Context, input PrepareDownloadInput) *PrepareDownloadOutput { | |
| 46 | + | log := logging.FromContext(ctx) | |
| 47 | + | ||
| 48 | + | // Resolve filename from multiple sources (priority: suggested > response suggested > URI) | |
| 49 | + | resolvedName := u.resolveSuggestedFilename(input.SuggestedFilename, input.Response) | |
| 50 | + | ||
| 51 | + | // Get MIME type for extension inference | |
| 52 | + | mimeType := "" | |
| 53 | + | if input.Response != nil { | |
| 54 | + | mimeType = input.Response.GetMimeType() | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Sanitize filename and add extension if needed | |
| 58 | + | safeName := download.SanitizeFilenameWithExtension(resolvedName, mimeType) | |
| 59 | + | ||
| 60 | + | // Make filename unique if file already exists | |
| 61 | + | if u.fs != nil { | |
| 62 | + | safeName = download.MakeUniqueFilename(input.DownloadDir, safeName, func(path string) bool { | |
| 63 | + | exists, err := u.fs.Exists(ctx, path) | |
| 64 | + | if err != nil { | |
| 65 | + | log.Warn().Err(err).Str("path", path). | |
| 66 | + | Msg("failed to check file existence; assuming exists") | |
| 67 | + | return true // Conservative: assume exists to avoid overwrite | |
| 68 | + | } | |
| 69 | + | return exists | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | destPath := filepath.Join(input.DownloadDir, safeName) | |
| 74 | + | ||
| 75 | + | log.Debug(). | |
| 76 | + | Str("suggested", input.SuggestedFilename). | |
| 77 | + | Str("resolved", resolvedName). | |
| 78 | + | Str("sanitized", safeName). | |
| 79 | + | Str("destPath", destPath). | |
| 80 | + | Msg("prepared download destination") | |
| 81 | + | ||
| 82 | + | return &PrepareDownloadOutput{ | |
| 83 | + | Filename: safeName, | |
| 84 | + | DestinationPath: destPath, | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // resolveSuggestedFilename determines the best filename from available sources. | |
| 89 | + | func (*PrepareDownloadUseCase) resolveSuggestedFilename(name string, response port.DownloadResponse) string { | |
| 90 | + | // Priority 1: Use provided suggested filename | |
| 91 | + | if name != "" { | |
| 92 | + | return name | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if response == nil { | |
| 96 | + | return name | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Priority 2: Use response's suggested filename (from Content-Disposition) | |
| 100 | + | if suggested := response.GetSuggestedFilename(); suggested != "" { | |
| 101 | + | return suggested | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Priority 3: Extract from URI path | |
| 105 | + | uri := response.GetUri() | |
| 106 | + | if uri == "" { | |
| 107 | + | return name | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | return download.ExtractFilenameFromURI(uri) | |
| 111 | + | } | |
A internal/application/usecase/prepare_download_test.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // mockDownloadResponse implements port.DownloadResponse for testing. | |
| 12 | + | type mockDownloadResponse struct { | |
| 13 | + | mimeType string | |
| 14 | + | suggestedFilename string | |
| 15 | + | uri string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (m *mockDownloadResponse) GetMimeType() string { return m.mimeType } | |
| 19 | + | func (m *mockDownloadResponse) GetSuggestedFilename() string { return m.suggestedFilename } | |
| 20 | + | func (m *mockDownloadResponse) GetUri() string { return m.uri } | |
| 21 | + | ||
| 22 | + | // mockFileSystem implements port.FileSystem for testing. | |
| 23 | + | type mockFileSystem struct { | |
| 24 | + | existingFiles map[string]bool | |
| 25 | + | existsErr error | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (m *mockFileSystem) Exists(_ context.Context, path string) (bool, error) { | |
| 29 | + | if m.existsErr != nil { | |
| 30 | + | return false, m.existsErr | |
| 31 | + | } | |
| 32 | + | return m.existingFiles[path], nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (*mockFileSystem) IsDirectory(_ context.Context, _ string) (bool, error) { | |
| 36 | + | return false, nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (*mockFileSystem) GetSize(_ context.Context, _ string) (int64, error) { | |
| 40 | + | return 0, nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (*mockFileSystem) RemoveAll(_ context.Context, _ string) error { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestPrepareDownloadUseCase_Execute(t *testing.T) { | |
| 48 | + | ctx := context.Background() | |
| 49 | + | uc := NewPrepareDownloadUseCase(nil) // nil FileSystem disables deduplication | |
| 50 | + | ||
| 51 | + | tests := []struct { | |
| 52 | + | name string | |
| 53 | + | input PrepareDownloadInput | |
| 54 | + | expectedFilename string | |
| 55 | + | expectedPath string | |
| 56 | + | }{ | |
| 57 | + | { | |
| 58 | + | name: "uses suggested filename directly", | |
| 59 | + | input: PrepareDownloadInput{ | |
| 60 | + | SuggestedFilename: "document.pdf", | |
| 61 | + | DownloadDir: "/tmp/downloads", | |
| 62 | + | }, | |
| 63 | + | expectedFilename: "document.pdf", | |
| 64 | + | expectedPath: "/tmp/downloads/document.pdf", | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | name: "sanitizes path traversal in suggested filename", | |
| 68 | + | input: PrepareDownloadInput{ | |
| 69 | + | SuggestedFilename: "../../../etc/passwd", | |
| 70 | + | DownloadDir: "/tmp/downloads", | |
| 71 | + | }, | |
| 72 | + | expectedFilename: "passwd", | |
| 73 | + | expectedPath: "/tmp/downloads/passwd", | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "falls back to response suggested filename", | |
| 77 | + | input: PrepareDownloadInput{ | |
| 78 | + | SuggestedFilename: "", | |
| 79 | + | Response: &mockDownloadResponse{ | |
| 80 | + | suggestedFilename: "from-header.pdf", | |
| 81 | + | }, | |
| 82 | + | DownloadDir: "/tmp/downloads", | |
| 83 | + | }, | |
| 84 | + | expectedFilename: "from-header.pdf", | |
| 85 | + | expectedPath: "/tmp/downloads/from-header.pdf", | |
| 86 | + | }, | |
| 87 | + | { | |
| 88 | + | name: "falls back to URI path", | |
| 89 | + | input: PrepareDownloadInput{ | |
| 90 | + | SuggestedFilename: "", | |
| 91 | + | Response: &mockDownloadResponse{ | |
| 92 | + | suggestedFilename: "", | |
| 93 | + | uri: "https://example.com/files/report.pdf", | |
| 94 | + | }, | |
| 95 | + | DownloadDir: "/tmp/downloads", | |
| 96 | + | }, | |
| 97 | + | expectedFilename: "report.pdf", | |
| 98 | + | expectedPath: "/tmp/downloads/report.pdf", | |
| 99 | + | }, | |
| 100 | + | { | |
| 101 | + | name: "adds extension from MIME type when missing", | |
| 102 | + | input: PrepareDownloadInput{ | |
| 103 | + | SuggestedFilename: "report", | |
| 104 | + | Response: &mockDownloadResponse{ | |
| 105 | + | mimeType: "application/pdf", | |
| 106 | + | }, | |
| 107 | + | DownloadDir: "/tmp/downloads", | |
| 108 | + | }, | |
| 109 | + | expectedFilename: "report.pdf", | |
| 110 | + | expectedPath: "/tmp/downloads/report.pdf", | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | name: "preserves existing extension even with MIME type", | |
| 114 | + | input: PrepareDownloadInput{ | |
| 115 | + | SuggestedFilename: "report.doc", | |
| 116 | + | Response: &mockDownloadResponse{ | |
| 117 | + | mimeType: "application/pdf", | |
| 118 | + | }, | |
| 119 | + | DownloadDir: "/tmp/downloads", | |
| 120 | + | }, | |
| 121 | + | expectedFilename: "report.doc", | |
| 122 | + | expectedPath: "/tmp/downloads/report.doc", | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "handles nil response", | |
| 126 | + | input: PrepareDownloadInput{ | |
| 127 | + | SuggestedFilename: "file.txt", | |
| 128 | + | Response: nil, | |
| 129 | + | DownloadDir: "/tmp/downloads", | |
| 130 | + | }, | |
| 131 | + | expectedFilename: "file.txt", | |
| 132 | + | expectedPath: "/tmp/downloads/file.txt", | |
| 133 | + | }, | |
| 134 | + | { | |
| 135 | + | name: "uses default filename for empty inputs", | |
| 136 | + | input: PrepareDownloadInput{ | |
| 137 | + | SuggestedFilename: "", | |
| 138 | + | Response: nil, | |
| 139 | + | DownloadDir: "/tmp/downloads", | |
| 140 | + | }, | |
| 141 | + | expectedFilename: "download", | |
| 142 | + | expectedPath: "/tmp/downloads/download", | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | name: "sanitizes windows path traversal", | |
| 146 | + | input: PrepareDownloadInput{ | |
| 147 | + | SuggestedFilename: "..\\..\\Windows\\System32\\config", | |
| 148 | + | DownloadDir: "/tmp/downloads", | |
| 149 | + | }, | |
| 150 | + | expectedFilename: "config", | |
| 151 | + | expectedPath: "/tmp/downloads/config", | |
| 152 | + | }, | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | for _, tt := range tests { | |
| 156 | + | t.Run(tt.name, func(t *testing.T) { | |
| 157 | + | result := uc.Execute(ctx, tt.input) | |
| 158 | + | ||
| 159 | + | assert.NotNil(t, result) | |
| 160 | + | assert.Equal(t, tt.expectedFilename, result.Filename) | |
| 161 | + | assert.Equal(t, tt.expectedPath, result.DestinationPath) | |
| 162 | + | }) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestPrepareDownloadUseCase_ResolvePriority(t *testing.T) { | |
| 167 | + | ctx := context.Background() | |
| 168 | + | uc := NewPrepareDownloadUseCase(nil) | |
| 169 | + | ||
| 170 | + | t.Run("suggested filename takes priority over response", func(t *testing.T) { | |
| 171 | + | input := PrepareDownloadInput{ | |
| 172 | + | SuggestedFilename: "priority.pdf", | |
| 173 | + | Response: &mockDownloadResponse{ | |
| 174 | + | suggestedFilename: "response.pdf", | |
| 175 | + | uri: "https://example.com/uri.pdf", | |
| 176 | + | }, | |
| 177 | + | DownloadDir: "/tmp", | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | result := uc.Execute(ctx, input) | |
| 181 | + | assert.Equal(t, "priority.pdf", result.Filename) | |
| 182 | + | }) | |
| 183 | + | ||
| 184 | + | t.Run("response suggested takes priority over URI", func(t *testing.T) { | |
| 185 | + | input := PrepareDownloadInput{ | |
| 186 | + | SuggestedFilename: "", | |
| 187 | + | Response: &mockDownloadResponse{ | |
| 188 | + | suggestedFilename: "response.pdf", | |
| 189 | + | uri: "https://example.com/uri.pdf", | |
| 190 | + | }, | |
| 191 | + | DownloadDir: "/tmp", | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | result := uc.Execute(ctx, input) | |
| 195 | + | assert.Equal(t, "response.pdf", result.Filename) | |
| 196 | + | }) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestPrepareDownloadUseCase_Deduplication(t *testing.T) { | |
| 200 | + | ctx := context.Background() | |
| 201 | + | ||
| 202 | + | t.Run("existing file triggers _(1) suffix", func(t *testing.T) { | |
| 203 | + | fs := &mockFileSystem{ | |
| 204 | + | existingFiles: map[string]bool{ | |
| 205 | + | "/tmp/downloads/document.pdf": true, | |
| 206 | + | }, | |
| 207 | + | } | |
| 208 | + | uc := NewPrepareDownloadUseCase(fs) | |
| 209 | + | ||
| 210 | + | input := PrepareDownloadInput{ | |
| 211 | + | SuggestedFilename: "document.pdf", | |
| 212 | + | DownloadDir: "/tmp/downloads", | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | result := uc.Execute(ctx, input) | |
| 216 | + | assert.Equal(t, "document_(1).pdf", result.Filename) | |
| 217 | + | assert.Equal(t, "/tmp/downloads/document_(1).pdf", result.DestinationPath) | |
| 218 | + | }) | |
| 219 | + | ||
| 220 | + | t.Run("multiple existing files increment suffix", func(t *testing.T) { | |
| 221 | + | fs := &mockFileSystem{ | |
| 222 | + | existingFiles: map[string]bool{ | |
| 223 | + | "/tmp/downloads/document.pdf": true, | |
| 224 | + | "/tmp/downloads/document_(1).pdf": true, | |
| 225 | + | }, | |
| 226 | + | } | |
| 227 | + | uc := NewPrepareDownloadUseCase(fs) | |
| 228 | + | ||
| 229 | + | input := PrepareDownloadInput{ | |
| 230 | + | SuggestedFilename: "document.pdf", | |
| 231 | + | DownloadDir: "/tmp/downloads", | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | result := uc.Execute(ctx, input) | |
| 235 | + | assert.Equal(t, "document_(2).pdf", result.Filename) | |
| 236 | + | assert.Equal(t, "/tmp/downloads/document_(2).pdf", result.DestinationPath) | |
| 237 | + | }) | |
| 238 | + | ||
| 239 | + | t.Run("fs.Exists error assumes file exists (conservative)", func(t *testing.T) { | |
| 240 | + | fs := &mockFileSystem{ | |
| 241 | + | existingFiles: map[string]bool{}, | |
| 242 | + | existsErr: errors.New("permission denied"), | |
| 243 | + | } | |
| 244 | + | uc := NewPrepareDownloadUseCase(fs) | |
| 245 | + | ||
| 246 | + | input := PrepareDownloadInput{ | |
| 247 | + | SuggestedFilename: "document.pdf", | |
| 248 | + | DownloadDir: "/tmp/downloads", | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | result := uc.Execute(ctx, input) | |
| 252 | + | // When fs.Exists returns an error, we conservatively assume file exists | |
| 253 | + | // This triggers deduplication to use timestamp fallback (after 1000 attempts) | |
| 254 | + | assert.NotEqual(t, "document.pdf", result.Filename) | |
| 255 | + | assert.Contains(t, result.Filename, "document_") | |
| 256 | + | }) | |
| 257 | + | ||
| 258 | + | t.Run("non-existing file uses original name", func(t *testing.T) { | |
| 259 | + | fs := &mockFileSystem{ | |
| 260 | + | existingFiles: map[string]bool{}, | |
| 261 | + | } | |
| 262 | + | uc := NewPrepareDownloadUseCase(fs) | |
| 263 | + | ||
| 264 | + | input := PrepareDownloadInput{ | |
| 265 | + | SuggestedFilename: "document.pdf", | |
| 266 | + | DownloadDir: "/tmp/downloads", | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | result := uc.Execute(ctx, input) | |
| 270 | + | assert.Equal(t, "document.pdf", result.Filename) | |
| 271 | + | assert.Equal(t, "/tmp/downloads/document.pdf", result.DestinationPath) | |
| 272 | + | }) | |
| 273 | + | } | |
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 | + | } | |
A internal/domain/autocomplete/autocomplete.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // Package autocomplete provides domain types and logic for URL autocompletion. | |
| 2 | + | package autocomplete | |
| 3 | + | ||
| 4 | + | import "strings" | |
| 5 | + | ||
| 6 | + | // SuggestionSource indicates the origin of an autocomplete suggestion. | |
| 7 | + | type SuggestionSource int | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | SourceHistory SuggestionSource = iota | |
| 11 | + | SourceFavorite | |
| 12 | + | SourceBangShortcut | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Suggestion represents a single autocomplete suggestion with its completion suffix. | |
| 16 | + | type Suggestion struct { | |
| 17 | + | FullText string // The complete text (URL or bang shortcut) | |
| 18 | + | Suffix string // The suffix to append after the user's input | |
| 19 | + | Source SuggestionSource // Where this suggestion came from | |
| 20 | + | Title string // Display title (page title or description) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // ComputeCompletionSuffix returns the suffix if input is a case-insensitive prefix of fullText. | |
| 24 | + | // Returns the suffix and true if input matches as a prefix, otherwise empty string and false. | |
| 25 | + | func ComputeCompletionSuffix(input, fullText string) (string, bool) { | |
| 26 | + | if input == "" || fullText == "" { | |
| 27 | + | return "", false | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | inputLower := strings.ToLower(input) | |
| 31 | + | fullLower := strings.ToLower(fullText) | |
| 32 | + | ||
| 33 | + | if !strings.HasPrefix(fullLower, inputLower) { | |
| 34 | + | return "", false | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Return the original-case suffix from fullText | |
| 38 | + | suffix := fullText[len(input):] | |
| 39 | + | return suffix, suffix != "" | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // StripProtocol removes http:// or https:// prefix from a URL for matching. | |
| 43 | + | func StripProtocol(url string) string { | |
| 44 | + | if strings.HasPrefix(url, "https://") { | |
| 45 | + | return url[8:] | |
| 46 | + | } | |
| 47 | + | if strings.HasPrefix(url, "http://") { | |
| 48 | + | return url[7:] | |
| 49 | + | } | |
| 50 | + | return url | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // ComputeURLCompletionSuffix computes the completion suffix for URLs, | |
| 54 | + | // handling protocol stripping for better matching. | |
| 55 | + | // It tries matching with and without protocol prefixes. | |
| 56 | + | func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL string, ok bool) { | |
| 57 | + | // First try direct match | |
| 58 | + | if suffix, ok := ComputeCompletionSuffix(input, fullURL); ok { | |
| 59 | + | return suffix, fullURL, true | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Try matching against URL without protocol | |
| 63 | + | strippedURL := StripProtocol(fullURL) | |
| 64 | + | if suffix, ok := ComputeCompletionSuffix(input, strippedURL); ok { | |
| 65 | + | return suffix, strippedURL, true | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Try matching if input has www. but URL doesn't (or vice versa) | |
| 69 | + | inputNoWWW := strings.TrimPrefix(input, "www.") | |
| 70 | + | strippedNoWWW := strings.TrimPrefix(strippedURL, "www.") | |
| 71 | + | if suffix, ok := ComputeCompletionSuffix(inputNoWWW, strippedNoWWW); ok { | |
| 72 | + | // Return suffix relative to input | |
| 73 | + | if strings.HasPrefix(strings.ToLower(strippedURL), strings.ToLower(input)) { | |
| 74 | + | return strippedURL[len(input):], strippedURL, true | |
| 75 | + | } | |
| 76 | + | return suffix, strippedNoWWW, true | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | return "", "", false | |
| 80 | + | } | |
A internal/domain/autocomplete/autocomplete_test.go +268 -0
| @@ -0,0 +1,268 @@ | |||
| 1 | + | package autocomplete | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestComputeCompletionSuffix(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | input string | |
| 9 | + | fullText string | |
| 10 | + | wantSuffix string | |
| 11 | + | wantOK bool | |
| 12 | + | }{ | |
| 13 | + | { | |
| 14 | + | name: "empty input", | |
| 15 | + | input: "", | |
| 16 | + | fullText: "example.com", | |
| 17 | + | wantSuffix: "", | |
| 18 | + | wantOK: false, | |
| 19 | + | }, | |
| 20 | + | { | |
| 21 | + | name: "empty fullText", | |
| 22 | + | input: "exa", | |
| 23 | + | fullText: "", | |
| 24 | + | wantSuffix: "", | |
| 25 | + | wantOK: false, | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "both empty", | |
| 29 | + | input: "", | |
| 30 | + | fullText: "", | |
| 31 | + | wantSuffix: "", | |
| 32 | + | wantOK: false, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "exact match returns false", | |
| 36 | + | input: "example.com", | |
| 37 | + | fullText: "example.com", | |
| 38 | + | wantSuffix: "", | |
| 39 | + | wantOK: false, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "prefix match", | |
| 43 | + | input: "exa", | |
| 44 | + | fullText: "example.com", | |
| 45 | + | wantSuffix: "mple.com", | |
| 46 | + | wantOK: true, | |
| 47 | + | }, | |
| 48 | + | { | |
| 49 | + | name: "case insensitive match", | |
| 50 | + | input: "EXA", | |
| 51 | + | fullText: "example.com", | |
| 52 | + | wantSuffix: "mple.com", | |
| 53 | + | wantOK: true, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "case insensitive preserves original case in suffix", | |
| 57 | + | input: "git", | |
| 58 | + | fullText: "GitHub.com", | |
| 59 | + | wantSuffix: "Hub.com", | |
| 60 | + | wantOK: true, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "no prefix match", | |
| 64 | + | input: "xyz", | |
| 65 | + | fullText: "example.com", | |
| 66 | + | wantSuffix: "", | |
| 67 | + | wantOK: false, | |
| 68 | + | }, | |
| 69 | + | { | |
| 70 | + | name: "input longer than fullText", | |
| 71 | + | input: "example.com/path", | |
| 72 | + | fullText: "example.com", | |
| 73 | + | wantSuffix: "", | |
| 74 | + | wantOK: false, | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | name: "single character prefix", | |
| 78 | + | input: "e", | |
| 79 | + | fullText: "example.com", | |
| 80 | + | wantSuffix: "xample.com", | |
| 81 | + | wantOK: true, | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "bang shortcut prefix", | |
| 85 | + | input: "!g", | |
| 86 | + | fullText: "!github", | |
| 87 | + | wantSuffix: "ithub", | |
| 88 | + | wantOK: true, | |
| 89 | + | }, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | for _, tt := range tests { | |
| 93 | + | t.Run(tt.name, func(t *testing.T) { | |
| 94 | + | suffix, ok := ComputeCompletionSuffix(tt.input, tt.fullText) | |
| 95 | + | if suffix != tt.wantSuffix { | |
| 96 | + | t.Errorf("ComputeCompletionSuffix(%q, %q) suffix = %q, want %q", | |
| 97 | + | tt.input, tt.fullText, suffix, tt.wantSuffix) | |
| 98 | + | } | |
| 99 | + | if ok != tt.wantOK { | |
| 100 | + | t.Errorf("ComputeCompletionSuffix(%q, %q) ok = %v, want %v", | |
| 101 | + | tt.input, tt.fullText, ok, tt.wantOK) | |
| 102 | + | } | |
| 103 | + | }) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestStripProtocol(t *testing.T) { | |
| 108 | + | tests := []struct { | |
| 109 | + | name string | |
| 110 | + | url string | |
| 111 | + | want string | |
| 112 | + | }{ | |
| 113 | + | { | |
| 114 | + | name: "https protocol", | |
| 115 | + | url: "https://example.com", | |
| 116 | + | want: "example.com", | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "http protocol", | |
| 120 | + | url: "http://example.com", | |
| 121 | + | want: "example.com", | |
| 122 | + | }, | |
| 123 | + | { | |
| 124 | + | name: "no protocol", | |
| 125 | + | url: "example.com", | |
| 126 | + | want: "example.com", | |
| 127 | + | }, | |
| 128 | + | { | |
| 129 | + | name: "https with path", | |
| 130 | + | url: "https://example.com/path/to/page", | |
| 131 | + | want: "example.com/path/to/page", | |
| 132 | + | }, | |
| 133 | + | { | |
| 134 | + | name: "http with query string", | |
| 135 | + | url: "http://example.com?query=value", | |
| 136 | + | want: "example.com?query=value", | |
| 137 | + | }, | |
| 138 | + | { | |
| 139 | + | name: "file protocol unchanged", | |
| 140 | + | url: "file:///path/to/file", | |
| 141 | + | want: "file:///path/to/file", | |
| 142 | + | }, | |
| 143 | + | { | |
| 144 | + | name: "empty string", | |
| 145 | + | url: "", | |
| 146 | + | want: "", | |
| 147 | + | }, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | for _, tt := range tests { | |
| 151 | + | t.Run(tt.name, func(t *testing.T) { | |
| 152 | + | got := StripProtocol(tt.url) | |
| 153 | + | if got != tt.want { | |
| 154 | + | t.Errorf("StripProtocol(%q) = %q, want %q", tt.url, got, tt.want) | |
| 155 | + | } | |
| 156 | + | }) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestComputeURLCompletionSuffix(t *testing.T) { | |
| 161 | + | tests := []struct { | |
| 162 | + | name string | |
| 163 | + | input string | |
| 164 | + | fullURL string | |
| 165 | + | wantSuffix string | |
| 166 | + | wantMatchedURL string | |
| 167 | + | wantOK bool | |
| 168 | + | }{ | |
| 169 | + | { | |
| 170 | + | name: "empty input", | |
| 171 | + | input: "", | |
| 172 | + | fullURL: "https://example.com", | |
| 173 | + | wantSuffix: "", | |
| 174 | + | wantMatchedURL: "", | |
| 175 | + | wantOK: false, | |
| 176 | + | }, | |
| 177 | + | { | |
| 178 | + | name: "direct match with protocol", | |
| 179 | + | input: "https://exa", | |
| 180 | + | fullURL: "https://example.com", | |
| 181 | + | wantSuffix: "mple.com", | |
| 182 | + | wantMatchedURL: "https://example.com", | |
| 183 | + | wantOK: true, | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | name: "match without protocol in input", | |
| 187 | + | input: "exa", | |
| 188 | + | fullURL: "https://example.com", | |
| 189 | + | wantSuffix: "mple.com", | |
| 190 | + | wantMatchedURL: "example.com", | |
| 191 | + | wantOK: true, | |
| 192 | + | }, | |
| 193 | + | { | |
| 194 | + | name: "match with www prefix in URL strips www", | |
| 195 | + | input: "exa", | |
| 196 | + | fullURL: "https://www.example.com", | |
| 197 | + | wantSuffix: "mple.com", | |
| 198 | + | wantMatchedURL: "example.com", | |
| 199 | + | wantOK: true, | |
| 200 | + | }, | |
| 201 | + | { | |
| 202 | + | name: "match with www in input but not URL", | |
| 203 | + | input: "www.exa", | |
| 204 | + | fullURL: "https://example.com", | |
| 205 | + | wantSuffix: "mple.com", | |
| 206 | + | wantMatchedURL: "example.com", | |
| 207 | + | wantOK: true, | |
| 208 | + | }, | |
| 209 | + | { | |
| 210 | + | name: "no match", | |
| 211 | + | input: "xyz", | |
| 212 | + | fullURL: "https://example.com", | |
| 213 | + | wantSuffix: "", | |
| 214 | + | wantMatchedURL: "", | |
| 215 | + | wantOK: false, | |
| 216 | + | }, | |
| 217 | + | { | |
| 218 | + | name: "case insensitive URL match", | |
| 219 | + | input: "GITHUB", | |
| 220 | + | fullURL: "https://github.com/user/repo", | |
| 221 | + | wantSuffix: ".com/user/repo", | |
| 222 | + | wantMatchedURL: "github.com/user/repo", | |
| 223 | + | wantOK: true, | |
| 224 | + | }, | |
| 225 | + | { | |
| 226 | + | name: "http protocol stripped", | |
| 227 | + | input: "news", | |
| 228 | + | fullURL: "http://news.ycombinator.com", | |
| 229 | + | wantSuffix: ".ycombinator.com", | |
| 230 | + | wantMatchedURL: "news.ycombinator.com", | |
| 231 | + | wantOK: true, | |
| 232 | + | }, | |
| 233 | + | { | |
| 234 | + | name: "full URL match returns false", | |
| 235 | + | input: "example.com", | |
| 236 | + | fullURL: "https://example.com", | |
| 237 | + | wantSuffix: "", | |
| 238 | + | wantMatchedURL: "", | |
| 239 | + | wantOK: false, | |
| 240 | + | }, | |
| 241 | + | { | |
| 242 | + | name: "URL with path", | |
| 243 | + | input: "github.com/user", | |
| 244 | + | fullURL: "https://github.com/user/repo", | |
| 245 | + | wantSuffix: "/repo", | |
| 246 | + | wantMatchedURL: "github.com/user/repo", | |
| 247 | + | wantOK: true, | |
| 248 | + | }, | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | for _, tt := range tests { | |
| 252 | + | t.Run(tt.name, func(t *testing.T) { | |
| 253 | + | suffix, matchedURL, ok := ComputeURLCompletionSuffix(tt.input, tt.fullURL) | |
| 254 | + | if suffix != tt.wantSuffix { | |
| 255 | + | t.Errorf("ComputeURLCompletionSuffix(%q, %q) suffix = %q, want %q", | |
| 256 | + | tt.input, tt.fullURL, suffix, tt.wantSuffix) | |
| 257 | + | } | |
| 258 | + | if matchedURL != tt.wantMatchedURL { | |
| 259 | + | t.Errorf("ComputeURLCompletionSuffix(%q, %q) matchedURL = %q, want %q", | |
| 260 | + | tt.input, tt.fullURL, matchedURL, tt.wantMatchedURL) | |
| 261 | + | } | |
| 262 | + | if ok != tt.wantOK { | |
| 263 | + | t.Errorf("ComputeURLCompletionSuffix(%q, %q) ok = %v, want %v", | |
| 264 | + | tt.input, tt.fullURL, ok, tt.wantOK) | |
| 265 | + | } | |
| 266 | + | }) | |
| 267 | + | } | |
| 268 | + | } | |
A internal/domain/download/filename.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package download | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "mime" | |
| 6 | + | "net/url" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | // DefaultFilename is used when no valid filename can be determined. | |
| 14 | + | DefaultFilename = "download" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // SanitizeFilename sanitizes a filename to prevent path traversal attacks. | |
| 18 | + | // It extracts only the base name and handles edge cases like "." or "..". | |
| 19 | + | func SanitizeFilename(name string) string { | |
| 20 | + | // Normalize Windows-style separators to forward slashes. | |
| 21 | + | // filepath.Base only handles the OS-native separator, so on Linux | |
| 22 | + | // backslashes would not be treated as path separators. | |
| 23 | + | name = strings.ReplaceAll(name, "\\", "/") | |
| 24 | + | ||
| 25 | + | // Get only the base name (removes any directory components). | |
| 26 | + | clean := filepath.Base(name) | |
| 27 | + | ||
| 28 | + | // If Base returns "." or ".." (edge cases), use fallback. | |
| 29 | + | if clean == "." || clean == ".." || clean == "" { | |
| 30 | + | return DefaultFilename | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | return clean | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // SanitizeFilenameWithExtension sanitizes a filename and adds an extension | |
| 37 | + | // inferred from the MIME type if the filename has no extension. | |
| 38 | + | func SanitizeFilenameWithExtension(name, mimeType string) string { | |
| 39 | + | clean := SanitizeFilename(name) | |
| 40 | + | if filepath.Ext(clean) == "" { | |
| 41 | + | if ext := GetExtensionFromMimeType(mimeType); ext != "" { | |
| 42 | + | return clean + ext | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | return clean | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // GetExtensionFromMimeType returns a file extension for a given MIME type. | |
| 50 | + | // Returns empty string if MIME type is unknown or empty. | |
| 51 | + | // Handles MIME types with parameters (e.g., "application/pdf; charset=binary"). | |
| 52 | + | func GetExtensionFromMimeType(mimeType string) string { | |
| 53 | + | if mimeType == "" { | |
| 54 | + | return "" | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Parse media type to extract bare type without parameters. | |
| 58 | + | // mime.ExtensionsByType expects "application/pdf", not "application/pdf; charset=binary". | |
| 59 | + | mediaType, _, err := mime.ParseMediaType(mimeType) | |
| 60 | + | if err != nil || mediaType == "" { | |
| 61 | + | return "" | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | exts, err := mime.ExtensionsByType(mediaType) | |
| 65 | + | if err != nil || len(exts) == 0 { | |
| 66 | + | return "" | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | return exts[0] | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // ExtractFilenameFromURI extracts the filename from a URI path component. | |
| 73 | + | // Handles both URIs and plain paths. Returns DefaultFilename for edge cases. | |
| 74 | + | func ExtractFilenameFromURI(uri string) string { | |
| 75 | + | if uri == "" { | |
| 76 | + | return DefaultFilename | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | parsed, err := url.Parse(uri) | |
| 80 | + | if err != nil { | |
| 81 | + | // Fall back to treating as plain path | |
| 82 | + | return extractFromPath(uri) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | return extractFromPath(parsed.Path) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // ExtractFilenameFromDestination extracts the filename from a file:// URI or path. | |
| 89 | + | // Used to get filename from WebKit download destination. | |
| 90 | + | func ExtractFilenameFromDestination(dest string) string { | |
| 91 | + | // Remove file:// prefix if present. | |
| 92 | + | path := strings.TrimPrefix(dest, "file://") | |
| 93 | + | base := filepath.Base(path) | |
| 94 | + | ||
| 95 | + | // Handle edge cases consistently with SanitizeFilename. | |
| 96 | + | if base == "." || base == "" { | |
| 97 | + | return DefaultFilename | |
| 98 | + | } | |
| 99 | + | return base | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // extractFromPath extracts filename from a path string. | |
| 103 | + | func extractFromPath(path string) string { | |
| 104 | + | base := filepath.Base(path) | |
| 105 | + | if base == "." || base == "" || base == "/" { | |
| 106 | + | return DefaultFilename | |
| 107 | + | } | |
| 108 | + | return base | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // MakeUniqueFilename generates a unique filename by appending _(N) if needed. | |
| 112 | + | // The exists function should return true if the given path already exists. | |
| 113 | + | func MakeUniqueFilename(dir, filename string, exists func(path string) bool) string { | |
| 114 | + | destPath := filepath.Join(dir, filename) | |
| 115 | + | if !exists(destPath) { | |
| 116 | + | return filename | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Split filename into base and extension | |
| 120 | + | ext := filepath.Ext(filename) | |
| 121 | + | base := strings.TrimSuffix(filename, ext) | |
| 122 | + | ||
| 123 | + | // Try incrementing numbers until we find a unique name | |
| 124 | + | for i := 1; i < 1000; i++ { | |
| 125 | + | candidate := fmt.Sprintf("%s_(%d)%s", base, i, ext) | |
| 126 | + | candidatePath := filepath.Join(dir, candidate) | |
| 127 | + | if !exists(candidatePath) { | |
| 128 | + | return candidate | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Fallback: use timestamp | |
| 133 | + | candidate := fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext) | |
| 134 | + | return candidate | |
| 135 | + | } | |
A internal/domain/download/filename_test.go +322 -0
| @@ -0,0 +1,322 @@ | |||
| 1 | + | package download | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSanitizeFilename(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | input string | |
| 13 | + | expected string | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "normal filename", | |
| 17 | + | input: "document.pdf", | |
| 18 | + | expected: "document.pdf", | |
| 19 | + | }, | |
| 20 | + | { | |
| 21 | + | name: "filename with spaces", | |
| 22 | + | input: "my document.pdf", | |
| 23 | + | expected: "my document.pdf", | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "path traversal with parent dirs", | |
| 27 | + | input: "../../../etc/passwd", | |
| 28 | + | expected: "passwd", | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "path traversal hidden file", | |
| 32 | + | input: "../.ssh/id_rsa", | |
| 33 | + | expected: "id_rsa", | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "nested path", | |
| 37 | + | input: "foo/bar/baz.txt", | |
| 38 | + | expected: "baz.txt", | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "absolute path", | |
| 42 | + | input: "/etc/passwd", | |
| 43 | + | expected: "passwd", | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "dot only", | |
| 47 | + | input: ".", | |
| 48 | + | expected: "download", | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "double dot only", | |
| 52 | + | input: "..", | |
| 53 | + | expected: "download", | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "empty string", | |
| 57 | + | input: "", | |
| 58 | + | expected: "download", | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "hidden file", | |
| 62 | + | input: ".bashrc", | |
| 63 | + | expected: ".bashrc", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "windows style path", | |
| 67 | + | input: "..\\..\\Windows\\System32\\config", | |
| 68 | + | expected: "config", | |
| 69 | + | }, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | for _, tt := range tests { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | result := SanitizeFilename(tt.input) | |
| 75 | + | assert.Equal(t, tt.expected, result) | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestSanitizeFilenameWithExtension(t *testing.T) { | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | input string | |
| 84 | + | mimeType string | |
| 85 | + | expected string | |
| 86 | + | }{ | |
| 87 | + | { | |
| 88 | + | name: "no extension adds inferred", | |
| 89 | + | input: "download", | |
| 90 | + | mimeType: "application/pdf", | |
| 91 | + | expected: "download.pdf", | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | name: "existing extension preserved", | |
| 95 | + | input: "report.pdf", | |
| 96 | + | mimeType: "application/pdf", | |
| 97 | + | expected: "report.pdf", | |
| 98 | + | }, | |
| 99 | + | { | |
| 100 | + | name: "path traversal with inferred extension", | |
| 101 | + | input: "../report", | |
| 102 | + | mimeType: "application/pdf", | |
| 103 | + | expected: "report.pdf", | |
| 104 | + | }, | |
| 105 | + | { | |
| 106 | + | name: "no mime type returns sanitized", | |
| 107 | + | input: "report", | |
| 108 | + | mimeType: "", | |
| 109 | + | expected: "report", | |
| 110 | + | }, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | for _, tt := range tests { | |
| 114 | + | t.Run(tt.name, func(t *testing.T) { | |
| 115 | + | result := SanitizeFilenameWithExtension(tt.input, tt.mimeType) | |
| 116 | + | assert.Equal(t, tt.expected, result) | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestGetExtensionFromMimeType(t *testing.T) { | |
| 122 | + | tests := []struct { | |
| 123 | + | name string | |
| 124 | + | input string | |
| 125 | + | expected string | |
| 126 | + | }{ | |
| 127 | + | { | |
| 128 | + | name: "empty mime type", | |
| 129 | + | input: "", | |
| 130 | + | expected: "", | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "unknown mime type", | |
| 134 | + | input: "application/unknown", | |
| 135 | + | expected: "", | |
| 136 | + | }, | |
| 137 | + | { | |
| 138 | + | name: "pdf mime type", | |
| 139 | + | input: "application/pdf", | |
| 140 | + | expected: ".pdf", | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | name: "pdf mime type with charset param", | |
| 144 | + | input: "application/pdf; charset=binary", | |
| 145 | + | expected: ".pdf", | |
| 146 | + | }, | |
| 147 | + | { | |
| 148 | + | name: "text html with charset", | |
| 149 | + | input: "text/html; charset=utf-8", | |
| 150 | + | expected: ".htm", | |
| 151 | + | }, | |
| 152 | + | { | |
| 153 | + | name: "mime type with multiple params", | |
| 154 | + | input: "application/json; charset=utf-8; boundary=something", | |
| 155 | + | expected: ".json", | |
| 156 | + | }, | |
| 157 | + | { | |
| 158 | + | name: "invalid mime type", | |
| 159 | + | input: "not-a-valid-mime", | |
| 160 | + | expected: "", | |
| 161 | + | }, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | for _, tt := range tests { | |
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |
| 166 | + | result := GetExtensionFromMimeType(tt.input) | |
| 167 | + | assert.Equal(t, tt.expected, result) | |
| 168 | + | }) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestExtractFilenameFromDestination(t *testing.T) { | |
| 173 | + | tests := []struct { | |
| 174 | + | name string | |
| 175 | + | input string | |
| 176 | + | expected string | |
| 177 | + | }{ | |
| 178 | + | { | |
| 179 | + | name: "file URI with path", | |
| 180 | + | input: "file:///home/user/Downloads/document.pdf", | |
| 181 | + | expected: "document.pdf", | |
| 182 | + | }, | |
| 183 | + | { | |
| 184 | + | name: "file URI root", | |
| 185 | + | input: "file:///document.pdf", | |
| 186 | + | expected: "document.pdf", | |
| 187 | + | }, | |
| 188 | + | { | |
| 189 | + | name: "plain path", | |
| 190 | + | input: "/home/user/Downloads/image.png", | |
| 191 | + | expected: "image.png", | |
| 192 | + | }, | |
| 193 | + | { | |
| 194 | + | name: "filename only", | |
| 195 | + | input: "video.mp4", | |
| 196 | + | expected: "video.mp4", | |
| 197 | + | }, | |
| 198 | + | { | |
| 199 | + | name: "empty string", | |
| 200 | + | input: "", | |
| 201 | + | expected: "download", | |
| 202 | + | }, | |
| 203 | + | { | |
| 204 | + | name: "file URI with spaces", | |
| 205 | + | input: "file:///home/user/My Documents/file.txt", | |
| 206 | + | expected: "file.txt", | |
| 207 | + | }, | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | for _, tt := range tests { | |
| 211 | + | t.Run(tt.name, func(t *testing.T) { | |
| 212 | + | result := ExtractFilenameFromDestination(tt.input) | |
| 213 | + | assert.Equal(t, tt.expected, result) | |
| 214 | + | }) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestExtractFilenameFromURI(t *testing.T) { | |
| 219 | + | tests := []struct { | |
| 220 | + | name string | |
| 221 | + | input string | |
| 222 | + | expected string | |
| 223 | + | }{ | |
| 224 | + | { | |
| 225 | + | name: "http URL with filename", | |
| 226 | + | input: "https://example.com/files/document.pdf", | |
| 227 | + | expected: "document.pdf", | |
| 228 | + | }, | |
| 229 | + | { | |
| 230 | + | name: "http URL with query params", | |
| 231 | + | input: "https://example.com/files/document.pdf?token=abc", | |
| 232 | + | expected: "document.pdf", | |
| 233 | + | }, | |
| 234 | + | { | |
| 235 | + | name: "http URL without filename", | |
| 236 | + | input: "https://example.com/", | |
| 237 | + | expected: "download", | |
| 238 | + | }, | |
| 239 | + | { | |
| 240 | + | name: "empty string", | |
| 241 | + | input: "", | |
| 242 | + | expected: "download", | |
| 243 | + | }, | |
| 244 | + | { | |
| 245 | + | name: "plain path", | |
| 246 | + | input: "/path/to/file.txt", | |
| 247 | + | expected: "file.txt", | |
| 248 | + | }, | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | for _, tt := range tests { | |
| 252 | + | t.Run(tt.name, func(t *testing.T) { | |
| 253 | + | result := ExtractFilenameFromURI(tt.input) | |
| 254 | + | assert.Equal(t, tt.expected, result) | |
| 255 | + | }) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func TestMakeUniqueFilename(t *testing.T) { | |
| 260 | + | tests := []struct { | |
| 261 | + | name string | |
| 262 | + | dir string | |
| 263 | + | filename string | |
| 264 | + | existingFiles map[string]bool | |
| 265 | + | expected string | |
| 266 | + | }{ | |
| 267 | + | { | |
| 268 | + | name: "file does not exist", | |
| 269 | + | dir: "/tmp", | |
| 270 | + | filename: "document.pdf", | |
| 271 | + | existingFiles: map[string]bool{}, | |
| 272 | + | expected: "document.pdf", | |
| 273 | + | }, | |
| 274 | + | { | |
| 275 | + | name: "file exists, adds _(1)", | |
| 276 | + | dir: "/tmp", | |
| 277 | + | filename: "document.pdf", | |
| 278 | + | existingFiles: map[string]bool{ | |
| 279 | + | "/tmp/document.pdf": true, | |
| 280 | + | }, | |
| 281 | + | expected: "document_(1).pdf", | |
| 282 | + | }, | |
| 283 | + | { | |
| 284 | + | name: "file and _(1) exist, adds _(2)", | |
| 285 | + | dir: "/tmp", | |
| 286 | + | filename: "document.pdf", | |
| 287 | + | existingFiles: map[string]bool{ | |
| 288 | + | "/tmp/document.pdf": true, | |
| 289 | + | "/tmp/document_(1).pdf": true, | |
| 290 | + | }, | |
| 291 | + | expected: "document_(2).pdf", | |
| 292 | + | }, | |
| 293 | + | { | |
| 294 | + | name: "no extension", | |
| 295 | + | dir: "/tmp", | |
| 296 | + | filename: "download", | |
| 297 | + | existingFiles: map[string]bool{ | |
| 298 | + | "/tmp/download": true, | |
| 299 | + | }, | |
| 300 | + | expected: "download_(1)", | |
| 301 | + | }, | |
| 302 | + | { | |
| 303 | + | name: "hidden file with extension", | |
| 304 | + | dir: "/tmp", | |
| 305 | + | filename: ".config.bak", | |
| 306 | + | existingFiles: map[string]bool{ | |
| 307 | + | "/tmp/.config.bak": true, | |
| 308 | + | }, | |
| 309 | + | expected: ".config_(1).bak", | |
| 310 | + | }, | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | for _, tt := range tests { | |
| 314 | + | t.Run(tt.name, func(t *testing.T) { | |
| 315 | + | exists := func(path string) bool { | |
| 316 | + | return tt.existingFiles[path] | |
| 317 | + | } | |
| 318 | + | result := MakeUniqueFilename(tt.dir, tt.filename, exists) | |
| 319 | + | assert.Equal(t, tt.expected, result) | |
| 320 | + | }) | |
| 321 | + | } | |
| 322 | + | } | |
M internal/domain/url/normalize.go +6 -0
| @@ -82,6 +82,9 @@ func Normalize(input string) string { | |||
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | // Looks like a URL (contains . and no spaces) | |
| 85 | + | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | |
| 86 | + | return "http://" + input | |
| 87 | + | } | |
| 85 | 88 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | |
| 86 | 89 | return "https://" + input | |
| 87 | 90 | } | |
| @@ -112,6 +115,9 @@ func LooksLikeURL(input string) bool { | |||
| 112 | 115 | } | |
| 113 | 116 | ||
| 114 | 117 | // Contains a dot and no spaces = likely a URL | |
| 118 | + | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | |
| 119 | + | return true | |
| 120 | + | } | |
| 115 | 121 | return strings.Contains(input, ".") && !strings.Contains(input, " ") | |
| 116 | 122 | } | |
| 117 | 123 | ||
M internal/domain/url/normalize_test.go +60 -0
| @@ -62,6 +62,36 @@ func TestNormalize(t *testing.T) { | |||
| 62 | 62 | input: "hello", | |
| 63 | 63 | want: "hello", | |
| 64 | 64 | }, | |
| 65 | + | { | |
| 66 | + | name: "localhost", | |
| 67 | + | input: "localhost", | |
| 68 | + | want: "http://localhost", | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "localhost with port", | |
| 72 | + | input: "localhost:5173", | |
| 73 | + | want: "http://localhost:5173", | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "localhost with alt port", | |
| 77 | + | input: "localhost:8080", | |
| 78 | + | want: "http://localhost:8080", | |
| 79 | + | }, | |
| 80 | + | { | |
| 81 | + | name: "localhost with path", | |
| 82 | + | input: "localhost/api/v1", | |
| 83 | + | want: "http://localhost/api/v1", | |
| 84 | + | }, | |
| 85 | + | { | |
| 86 | + | name: "localhost.com is a domain not localhost", | |
| 87 | + | input: "localhost.com", | |
| 88 | + | want: "https://localhost.com", | |
| 89 | + | }, | |
| 90 | + | { | |
| 91 | + | name: "localhostevil.com is a domain not localhost", | |
| 92 | + | input: "localhostevil.com", | |
| 93 | + | want: "https://localhostevil.com", | |
| 94 | + | }, | |
| 65 | 95 | } | |
| 66 | 96 | ||
| 67 | 97 | for _, tt := range tests { | |
| @@ -257,6 +287,36 @@ func TestLooksLikeURL(t *testing.T) { | |||
| 257 | 287 | input: "hello", | |
| 258 | 288 | want: false, | |
| 259 | 289 | }, | |
| 290 | + | { | |
| 291 | + | name: "localhost", | |
| 292 | + | input: "localhost", | |
| 293 | + | want: true, | |
| 294 | + | }, | |
| 295 | + | { | |
| 296 | + | name: "localhost with port", | |
| 297 | + | input: "localhost:5173", | |
| 298 | + | want: true, | |
| 299 | + | }, | |
| 300 | + | { | |
| 301 | + | name: "localhost with alt port", | |
| 302 | + | input: "localhost:8080", | |
| 303 | + | want: true, | |
| 304 | + | }, | |
| 305 | + | { | |
| 306 | + | name: "localhost with path", | |
| 307 | + | input: "localhost/api/v1", | |
| 308 | + | want: true, | |
| 309 | + | }, | |
| 310 | + | { | |
| 311 | + | name: "localhost.com is a domain not localhost", | |
| 312 | + | input: "localhost.com", | |
| 313 | + | want: true, | |
| 314 | + | }, | |
| 315 | + | { | |
| 316 | + | name: "localhostevil.com is a domain not localhost", | |
| 317 | + | input: "localhostevil.com", | |
| 318 | + | want: true, | |
| 319 | + | }, | |
| 260 | 320 | } | |
| 261 | 321 | ||
| 262 | 322 | for _, tt := range tests { | |
M internal/infrastructure/config/defaults.go +53 -52
| @@ -161,40 +161,40 @@ func DefaultConfig() *Config { | |||
| 161 | 161 | PaneMode: PaneModeConfig{ | |
| 162 | 162 | ActivationShortcut: defaultPaneActivationShortcut, | |
| 163 | 163 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | |
| 164 | - | Actions: map[string][]string{ | |
| 165 | - | "split-right": {"arrowright", "r"}, | |
| 166 | - | "split-left": {"arrowleft", "l"}, | |
| 167 | - | "split-up": {"arrowup", "u"}, | |
| 168 | - | "split-down": {"arrowdown", "d"}, | |
| 169 | - | "stack-pane": {"s"}, | |
| 170 | - | "close-pane": {"x"}, | |
| 171 | - | "move-pane-to-tab": {"m"}, | |
| 172 | - | "move-pane-to-next-tab": {"M", "shift+m"}, | |
| 164 | + | Actions: map[string]ActionBinding{ | |
| 165 | + | "split-right": {Keys: []string{"arrowright", "r"}, Desc: "Split pane to the right"}, | |
| 166 | + | "split-left": {Keys: []string{"arrowleft", "l"}, Desc: "Split pane to the left"}, | |
| 167 | + | "split-up": {Keys: []string{"arrowup", "u"}, Desc: "Split pane upward"}, | |
| 168 | + | "split-down": {Keys: []string{"arrowdown", "d"}, Desc: "Split pane downward"}, | |
| 169 | + | "stack-pane": {Keys: []string{"s"}, Desc: "Stack pane with sibling"}, | |
| 170 | + | "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"}, | |
| 171 | + | "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"}, | |
| 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", "["}, | |
| 175 | - | "consume-or-expel-right": {"bracketright", "]"}, | |
| 176 | - | "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"}, | |
| 177 | - | "consume-or-expel-down": {"shift+bracketright", "braceright", "}"}, | |
| 174 | + | "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"}, | |
| 175 | + | "consume-or-expel-right": {Keys: []string{"]"}, Desc: "Consume/expel pane right"}, | |
| 176 | + | "consume-or-expel-up": {Keys: []string{"{"}, Desc: "Consume/expel pane up"}, | |
| 177 | + | "consume-or-expel-down": {Keys: []string{"}"}, Desc: "Consume/expel pane down"}, | |
| 178 | 178 | ||
| 179 | - | "focus-right": {"shift+arrowright", "shift+l"}, | |
| 180 | - | "focus-left": {"shift+arrowleft", "shift+h"}, | |
| 181 | - | "focus-up": {"shift+arrowup", "shift+k"}, | |
| 182 | - | "focus-down": {"shift+arrowdown", "shift+j"}, | |
| 183 | - | "confirm": {"enter"}, | |
| 184 | - | "cancel": {"escape"}, | |
| 179 | + | "focus-right": {Keys: []string{"shift+arrowright", "shift+l"}, Desc: "Focus pane to the right"}, | |
| 180 | + | "focus-left": {Keys: []string{"shift+arrowleft", "shift+h"}, Desc: "Focus pane to the left"}, | |
| 181 | + | "focus-up": {Keys: []string{"shift+arrowup", "shift+k"}, Desc: "Focus pane above"}, | |
| 182 | + | "focus-down": {Keys: []string{"shift+arrowdown", "shift+j"}, Desc: "Focus pane below"}, | |
| 183 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |
| 184 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |
| 185 | 185 | }, | |
| 186 | 186 | }, | |
| 187 | 187 | TabMode: TabModeConfig{ | |
| 188 | 188 | ActivationShortcut: defaultTabActivationShortcut, | |
| 189 | 189 | TimeoutMilliseconds: defaultTabTimeoutMilliseconds, | |
| 190 | - | Actions: map[string][]string{ | |
| 191 | - | "new-tab": {"n", "c"}, | |
| 192 | - | "close-tab": {"x"}, | |
| 193 | - | "next-tab": {"l", "tab"}, | |
| 194 | - | "previous-tab": {"h", "shift+tab"}, | |
| 195 | - | "rename-tab": {"r"}, | |
| 196 | - | "confirm": {"enter"}, | |
| 197 | - | "cancel": {"escape"}, | |
| 190 | + | Actions: map[string]ActionBinding{ | |
| 191 | + | "new-tab": {Keys: []string{"n", "c"}, Desc: "Create new tab"}, | |
| 192 | + | "close-tab": {Keys: []string{"x"}, Desc: "Close current tab"}, | |
| 193 | + | "next-tab": {Keys: []string{"l", "tab"}, Desc: "Switch to next tab"}, | |
| 194 | + | "previous-tab": {Keys: []string{"h", "shift+tab"}, Desc: "Switch to previous tab"}, | |
| 195 | + | "rename-tab": {Keys: []string{"r"}, Desc: "Rename current tab"}, | |
| 196 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |
| 197 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |
| 198 | 198 | }, | |
| 199 | 199 | }, | |
| 200 | 200 | ResizeMode: ResizeModeConfig{ | |
| @@ -202,30 +202,31 @@ func DefaultConfig() *Config { | |||
| 202 | 202 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | |
| 203 | 203 | StepPercent: defaultResizeStepPercent, | |
| 204 | 204 | MinPanePercent: defaultResizeMinPanePercent, | |
| 205 | - | Actions: map[string][]string{ | |
| 206 | - | "resize-increase-left": {"h", "arrowleft"}, | |
| 207 | - | "resize-increase-down": {"j", "arrowdown"}, | |
| 208 | - | "resize-increase-up": {"k", "arrowup"}, | |
| 209 | - | "resize-increase-right": {"l", "arrowright"}, | |
| 210 | - | "resize-decrease-left": {"H"}, | |
| 211 | - | "resize-decrease-down": {"J"}, | |
| 212 | - | "resize-decrease-up": {"K"}, | |
| 213 | - | "resize-decrease-right": {"L"}, | |
| 214 | - | "resize-increase": {"+", "="}, | |
| 215 | - | "resize-decrease": {"-"}, | |
| 216 | - | "confirm": {"enter"}, | |
| 217 | - | "cancel": {"escape"}, | |
| 205 | + | Actions: map[string]ActionBinding{ | |
| 206 | + | "resize-increase-left": {Keys: []string{"h", "arrowleft"}, Desc: "Increase size leftward"}, | |
| 207 | + | "resize-increase-down": {Keys: []string{"j", "arrowdown"}, Desc: "Increase size downward"}, | |
| 208 | + | "resize-increase-up": {Keys: []string{"k", "arrowup"}, Desc: "Increase size upward"}, | |
| 209 | + | "resize-increase-right": {Keys: []string{"l", "arrowright"}, Desc: "Increase size rightward"}, | |
| 210 | + | "resize-decrease-left": {Keys: []string{"H"}, Desc: "Decrease size leftward"}, | |
| 211 | + | "resize-decrease-down": {Keys: []string{"J"}, Desc: "Decrease size downward"}, | |
| 212 | + | "resize-decrease-up": {Keys: []string{"K"}, Desc: "Decrease size upward"}, | |
| 213 | + | "resize-decrease-right": {Keys: []string{"L"}, Desc: "Decrease size rightward"}, | |
| 214 | + | "resize-increase": {Keys: []string{"+", "="}, Desc: "Increase pane size"}, | |
| 215 | + | "resize-decrease": {Keys: []string{"-"}, Desc: "Decrease pane size"}, | |
| 216 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |
| 217 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |
| 218 | 218 | }, | |
| 219 | 219 | }, | |
| 220 | 220 | Shortcuts: GlobalShortcutsConfig{ | |
| 221 | - | ClosePane: "ctrl+w", | |
| 222 | - | NextTab: "ctrl+tab", | |
| 223 | - | PreviousTab: "ctrl+shift+tab", | |
| 224 | - | ||
| 225 | - | ConsumeOrExpelLeft: "alt+bracketleft", | |
| 226 | - | ConsumeOrExpelRight: "alt+bracketright", | |
| 227 | - | ConsumeOrExpelUp: "alt+shift+bracketleft", | |
| 228 | - | ConsumeOrExpelDown: "alt+shift+bracketright", | |
| 221 | + | Actions: map[string]ActionBinding{ | |
| 222 | + | "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, | |
| 223 | + | "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, | |
| 224 | + | "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, | |
| 225 | + | "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, | |
| 226 | + | "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | |
| 227 | + | "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | |
| 228 | + | "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | |
| 229 | + | }, | |
| 229 | 230 | }, | |
| 230 | 231 | TabBarPosition: defaultTabBarPosition, | |
| 231 | 232 | HideTabBarWhenSingleTab: true, | |
| @@ -269,10 +270,10 @@ func DefaultConfig() *Config { | |||
| 269 | 270 | SessionMode: SessionModeConfig{ | |
| 270 | 271 | ActivationShortcut: defaultSessionActivationShortcut, | |
| 271 | 272 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | |
| 272 | - | Actions: map[string][]string{ | |
| 273 | - | "session-manager": {"s", "w"}, | |
| 274 | - | "confirm": {"enter"}, | |
| 275 | - | "cancel": {"escape"}, | |
| 273 | + | Actions: map[string]ActionBinding{ | |
| 274 | + | "session-manager": {Keys: []string{"s", "w"}, Desc: "Open session manager"}, | |
| 275 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |
| 276 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |
| 276 | 277 | }, | |
| 277 | 278 | }, | |
| 278 | 279 | }, | |
A internal/infrastructure/config/keybindings_gateway.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 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 | + | if binding, ok := defaults.Workspace.PaneMode.Actions[action]; ok { | |
| 233 | + | return binding.Keys | |
| 234 | + | } | |
| 235 | + | case "tab": | |
| 236 | + | if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok { | |
| 237 | + | return binding.Keys | |
| 238 | + | } | |
| 239 | + | case "resize": | |
| 240 | + | if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok { | |
| 241 | + | return binding.Keys | |
| 242 | + | } | |
| 243 | + | case "session": | |
| 244 | + | if binding, ok := defaults.Session.SessionMode.Actions[action]; ok { | |
| 245 | + | return binding.Keys | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // checkConflicts scans all modes for keybinding conflicts. | |
| 252 | + | func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction string, newKeys []string) []port.KeybindingConflict { | |
| 253 | + | var conflicts []port.KeybindingConflict | |
| 254 | + | ||
| 255 | + | type binding struct { | |
| 256 | + | mode string | |
| 257 | + | action string | |
| 258 | + | } | |
| 259 | + | keyMap := make(map[string][]binding) | |
| 260 | + | ||
| 261 | + | addBindings := func(mode string, actions map[string]ActionBinding) { | |
| 262 | + | for action, ab := range actions { | |
| 263 | + | for _, key := range ab.Keys { | |
| 264 | + | keyMap[key] = append(keyMap[key], binding{mode: mode, action: action}) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | |
| 270 | + | addBindings("pane", cfg.Workspace.PaneMode.Actions) | |
| 271 | + | addBindings("tab", cfg.Workspace.TabMode.Actions) | |
| 272 | + | addBindings("resize", cfg.Workspace.ResizeMode.Actions) | |
| 273 | + | addBindings("session", cfg.Session.SessionMode.Actions) | |
| 274 | + | ||
| 275 | + | for _, newKey := range newKeys { | |
| 276 | + | if existing, ok := keyMap[newKey]; ok { | |
| 277 | + | for _, b := range existing { | |
| 278 | + | if b.mode == targetMode && b.action == targetAction { | |
| 279 | + | continue | |
| 280 | + | } | |
| 281 | + | if targetMode == modeGlobal || b.mode == modeGlobal || b.mode == targetMode { | |
| 282 | + | conflicts = append(conflicts, port.KeybindingConflict{ | |
| 283 | + | ConflictingAction: b.action, | |
| 284 | + | ConflictingMode: b.mode, | |
| 285 | + | Key: newKey, | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | return conflicts | |
| 293 | + | } | |
M internal/infrastructure/config/loader.go +61 -50
| @@ -17,11 +17,12 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // Manager handles configuration loading, watching, and reloading. | |
| 19 | 19 | type Manager struct { | |
| 20 | - | config *Config | |
| 21 | - | viper *viper.Viper | |
| 22 | - | mu sync.RWMutex | |
| 23 | - | callbacks []func(*Config) | |
| 24 | - | watching bool | |
| 20 | + | config *Config | |
| 21 | + | viper *viper.Viper | |
| 22 | + | mu sync.RWMutex | |
| 23 | + | callbacks []func(*Config) | |
| 24 | + | watching bool | |
| 25 | + | skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | // NewManager creates a new configuration manager. | |
| @@ -130,9 +131,26 @@ func (m *Manager) readConfigFile() error { | |||
| 130 | 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 | 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 | 154 | func (m *Manager) unmarshalConfig() (*Config, error) { | |
| 137 | 155 | config := &Config{} | |
| 138 | 156 | if err := m.viper.Unmarshal(config); err != nil { | |
| @@ -242,7 +260,7 @@ func (m *Manager) Get() *Config { | |||
| 242 | 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 | 264 | func (m *Manager) Save(cfg *Config) error { | |
| 247 | 265 | m.mu.Lock() | |
| 248 | 266 | defer m.mu.Unlock() | |
| @@ -256,31 +274,32 @@ func (m *Manager) Save(cfg *Config) error { | |||
| 256 | 274 | return fmt.Errorf("configuration validation failed: %w", err) | |
| 257 | 275 | } | |
| 258 | 276 | ||
| 259 | - | // Update Viper with the new values. | |
| 260 | - | // Since we can't easily update Viper from a struct automatically while preserving | |
| 261 | - | // all settings, we update the main keys we care about. | |
| 262 | - | m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont) | |
| 263 | - | m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont) | |
| 264 | - | m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont) | |
| 265 | - | m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize) | |
| 266 | - | m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme) | |
| 267 | - | m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette) | |
| 268 | - | m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette) | |
| 269 | - | m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom) | |
| 270 | - | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | |
| 271 | - | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | |
| 272 | - | ||
| 273 | - | // Performance profile (requires browser restart to take effect) | |
| 274 | - | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | |
| 275 | - | ||
| 276 | - | if err := m.viper.WriteConfig(); err != nil { | |
| 277 | + | // Write config with ordered sections (bypasses Viper's random map ordering) | |
| 278 | + | configFile := m.viper.ConfigFileUsed() | |
| 279 | + | if configFile == "" { | |
| 280 | + | var err error | |
| 281 | + | configFile, err = GetConfigFile() | |
| 282 | + | if err != nil { | |
| 283 | + | return fmt.Errorf("failed to determine config file path: %w", err) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // Set flag BEFORE writing to prevent fsnotify-triggered reload from | |
| 288 | + | // overwriting our in-memory config with stale viper cache | |
| 289 | + | m.skipNextReload = true | |
| 290 | + | ||
| 291 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { | |
| 292 | + | m.skipNextReload = false // Reset on error | |
| 277 | 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 | |
| 281 | - | // will trigger reload() if Watch() is active. | |
| 282 | - | // If Watch() is not active, we should call reload() manually. | |
| 296 | + | // Update in-memory config immediately (don't wait for file watcher) | |
| 297 | + | // This ensures subsequent Get() calls return the new values | |
| 298 | + | m.config = cfg | |
| 299 | + | ||
| 300 | + | // Also reload from disk to sync Viper's internal state (if not watching) | |
| 283 | 301 | if !m.watching { | |
| 302 | + | m.skipNextReload = false // Clear flag since we're doing manual reload | |
| 284 | 303 | if err := m.reload(); err != nil { | |
| 285 | 304 | return err | |
| 286 | 305 | } | |
| @@ -294,7 +313,7 @@ func (m *Manager) GetConfigFile() string { | |||
| 294 | 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 | 317 | func (m *Manager) createDefaultConfig() error { | |
| 299 | 318 | configFile, err := GetConfigFile() | |
| 300 | 319 | if err != nil { | |
| @@ -306,12 +325,14 @@ func (m *Manager) createDefaultConfig() error { | |||
| 306 | 325 | return err | |
| 307 | 326 | } | |
| 308 | 327 | ||
| 309 | - | // Detect available fonts and override defaults before writing config. | |
| 310 | - | m.detectAndSetFonts() | |
| 328 | + | // Start with default config | |
| 329 | + | cfg := DefaultConfig() | |
| 330 | + | ||
| 331 | + | // Detect available fonts and override defaults | |
| 332 | + | m.detectAndSetFontsOnConfig(cfg) | |
| 311 | 333 | ||
| 312 | - | // Ensure TOML format and write config | |
| 313 | - | m.viper.SetConfigType("toml") | |
| 314 | - | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |
| 334 | + | // Write config with ordered sections | |
| 335 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { | |
| 315 | 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 | 341 | return nil | |
| 321 | 342 | } | |
| 322 | 343 | ||
| 323 | - | // detectAndSetFonts detects available system fonts and sets the best available | |
| 324 | - | // fonts from the fallback chains. This runs only during first-run config creation. | |
| 325 | - | func (m *Manager) detectAndSetFonts() { | |
| 344 | + | // detectAndSetFontsOnConfig detects available system fonts and sets the best available | |
| 345 | + | // fonts from the fallback chains directly on the Config struct. | |
| 346 | + | func (*Manager) detectAndSetFontsOnConfig(cfg *Config) { | |
| 326 | 347 | // Create context with logger for debugging first-run font detection. | |
| 327 | 348 | logger := logging.NewFromEnv() | |
| 328 | 349 | ctx := logging.WithContext(context.Background(), logger) | |
| @@ -334,13 +355,9 @@ func (m *Manager) detectAndSetFonts() { | |||
| 334 | 355 | return | |
| 335 | 356 | } | |
| 336 | 357 | ||
| 337 | - | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |
| 338 | - | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |
| 339 | - | monoFont := 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) | |
| 358 | + | cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |
| 359 | + | cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |
| 360 | + | cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | |
| 344 | 361 | } | |
| 345 | 362 | ||
| 346 | 363 | // setDefaults sets default configuration values in Viper. | |
| @@ -443,13 +460,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 443 | 460 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | |
| 444 | 461 | m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent) | |
| 445 | 462 | m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions) | |
| 446 | - | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | |
| 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) | |
| 463 | + | m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions) | |
| 453 | 464 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 454 | 465 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | |
| 455 | 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 | 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 | 378 | // Build sets of keys to remove and renames to apply | |
| 375 | 379 | keysToRemove := make(map[string]bool) | |
| 376 | 380 | renames := make(map[string]string) // oldKey -> newKey | |
| @@ -416,8 +420,14 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 416 | 420 | return nil, fmt.Errorf("failed to merge config: %w", err) | |
| 417 | 421 | } | |
| 418 | 422 | ||
| 419 | - | // Write the merged config back | |
| 420 | - | if err := userViper.WriteConfig(); err != nil { | |
| 423 | + | // Unmarshal to Config struct for ordered writing | |
| 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 | 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 | 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 | 652 | // Flatten the map to dot-notation keys with values | |
| 639 | 653 | result := make(map[string]any) | |
| 640 | 654 | m.flattenMapWithValues(rawConfig, "", result) | |
M internal/infrastructure/config/schema.go +39 -29
| @@ -410,19 +410,25 @@ type WorkspaceConfig struct { | |||
| 410 | 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 | 419 | // PaneModeConfig defines modal behavior for pane management. | |
| 414 | 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 | |
| 416 | - | 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"` | |
| 421 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 422 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 423 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 418 | 424 | } | |
| 419 | 425 | ||
| 420 | 426 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 421 | 427 | // This is built from the action→keys structure in the config. | |
| 422 | 428 | func (p *PaneModeConfig) GetKeyBindings() map[string]string { | |
| 423 | 429 | keyToAction := make(map[string]string) | |
| 424 | - | for action, keys := range p.Actions { | |
| 425 | - | for _, key := range keys { | |
| 430 | + | for action, binding := range p.Actions { | |
| 431 | + | for _, key := range binding.Keys { | |
| 426 | 432 | keyToAction[key] = action | |
| 427 | 433 | } | |
| 428 | 434 | } | |
| @@ -431,17 +437,17 @@ func (p *PaneModeConfig) GetKeyBindings() map[string]string { | |||
| 431 | 437 | ||
| 432 | 438 | // TabModeConfig defines modal behavior for tab management (Zellij-style). | |
| 433 | 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 | |
| 435 | - | 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"` | |
| 440 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 441 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 442 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 437 | 443 | } | |
| 438 | 444 | ||
| 439 | 445 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 440 | 446 | // This is built from the action→keys structure in the config. | |
| 441 | 447 | func (t *TabModeConfig) GetKeyBindings() map[string]string { | |
| 442 | 448 | keyToAction := make(map[string]string) | |
| 443 | - | for action, keys := range t.Actions { | |
| 444 | - | for _, key := range keys { | |
| 449 | + | for action, binding := range t.Actions { | |
| 450 | + | for _, key := range binding.Keys { | |
| 445 | 451 | keyToAction[key] = action | |
| 446 | 452 | } | |
| 447 | 453 | } | |
| @@ -450,19 +456,19 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||
| 450 | 456 | ||
| 451 | 457 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | |
| 452 | 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 | |
| 454 | - | 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"` | |
| 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 | |
| 457 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 459 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 460 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 461 | + | StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"` | |
| 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 | |
| 463 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 458 | 464 | } | |
| 459 | 465 | ||
| 460 | 466 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 461 | 467 | // This is built from the action→keys structure in the config. | |
| 462 | 468 | func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | |
| 463 | 469 | keyToAction := make(map[string]string) | |
| 464 | - | for action, keys := range r.Actions { | |
| 465 | - | for _, key := range keys { | |
| 470 | + | for action, binding := range r.Actions { | |
| 471 | + | for _, key := range binding.Keys { | |
| 466 | 472 | keyToAction[key] = action | |
| 467 | 473 | } | |
| 468 | 474 | } | |
| @@ -471,14 +477,18 @@ func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | |||
| 471 | 477 | ||
| 472 | 478 | // GlobalShortcutsConfig defines global shortcuts (always active, not modal). | |
| 473 | 479 | type GlobalShortcutsConfig struct { | |
| 474 | - | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | |
| 475 | - | NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"` | |
| 476 | - | PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"` | |
| 480 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 481 | + | } | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 483 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 484 | + | func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string { | |
| 485 | + | keyToAction := make(map[string]string) | |
| 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 | 494 | // PopupBehavior defines how popup windows should be opened | |
| @@ -574,17 +584,17 @@ type SessionConfig struct { | |||
| 574 | 584 | ||
| 575 | 585 | // SessionModeConfig defines modal behavior for session management. | |
| 576 | 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 | |
| 578 | - | 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"` | |
| 587 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 588 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 589 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 580 | 590 | } | |
| 581 | 591 | ||
| 582 | 592 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 583 | 593 | // This is built from the action→keys structure in the config. | |
| 584 | 594 | func (s *SessionModeConfig) GetKeyBindings() map[string]string { | |
| 585 | 595 | keyToAction := make(map[string]string) | |
| 586 | - | for action, keys := range s.Actions { | |
| 587 | - | for _, key := range keys { | |
| 596 | + | for action, binding := range s.Actions { | |
| 597 | + | for _, key := range binding.Keys { | |
| 588 | 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 | 445 | Description: "Key bindings for resize mode actions", | |
| 446 | 446 | Section: SectionWorkspace, | |
| 447 | 447 | }, | |
| 448 | - | // Global shortcuts | |
| 448 | + | // Global shortcuts - managed via dedicated keybindings UI | |
| 449 | 449 | { | |
| 450 | - | Key: "workspace.shortcuts.close_pane", | |
| 451 | - | Type: "string", | |
| 452 | - | Default: defaults.Workspace.Shortcuts.ClosePane, | |
| 453 | - | Description: "Global shortcut to close current pane", | |
| 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)", | |
| 450 | + | Key: "workspace.shortcuts.actions", | |
| 451 | + | Type: "object", | |
| 452 | + | Default: "(see defaults)", | |
| 453 | + | Description: "Global keyboard shortcuts (use Keybindings tab to edit)", | |
| 496 | 454 | Section: SectionWorkspace, | |
| 497 | 455 | }, | |
| 498 | 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 | 168 | seenKeys := make(map[string]string) | |
| 169 | - | for action, keys := range config.Workspace.PaneMode.Actions { | |
| 170 | - | if len(keys) == 0 { | |
| 169 | + | for action, binding := range config.Workspace.PaneMode.Actions { | |
| 170 | + | if len(binding.Keys) == 0 { | |
| 171 | 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 | 174 | if existingAction, exists := seenKeys[key]; exists { | |
| 175 | 175 | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 176 | 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 | 206 | tabSeenKeys := make(map[string]string) | |
| 207 | - | for action, keys := range config.Workspace.TabMode.Actions { | |
| 208 | - | if len(keys) == 0 { | |
| 207 | + | for action, binding := range config.Workspace.TabMode.Actions { | |
| 208 | + | if len(binding.Keys) == 0 { | |
| 209 | 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 | 212 | if existingAction, exists := tabSeenKeys[key]; exists { | |
| 213 | 213 | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 214 | 214 | "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", | |
M internal/infrastructure/config/watcher.go +47 -14
| @@ -2,8 +2,8 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "os" | |
| 6 | 5 | ||
| 6 | + | "github.com/bnema/dumber/internal/logging" | |
| 7 | 7 | "github.com/fsnotify/fsnotify" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| @@ -17,29 +17,62 @@ func (m *Manager) Watch() error { | |||
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | m.viper.WatchConfig() | |
| 20 | - | m.viper.OnConfigChange(func(_ fsnotify.Event) { | |
| 21 | - | // Reload config | |
| 22 | - | if err := m.reload(); err != nil { | |
| 23 | - | fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err) | |
| 20 | + | m.viper.OnConfigChange(func(e fsnotify.Event) { | |
| 21 | + | log := logging.NewFromEnv() | |
| 22 | + | log.Debug().Str("op", e.Op.String()).Str("file", e.Name).Msg("fsnotify config change detected") | |
| 23 | + | ||
| 24 | + | // Acquire write lock before reload (reload modifies m.config) | |
| 25 | + | m.mu.Lock() | |
| 26 | + | ||
| 27 | + | // Track whether notifyCallbacksLocked will handle the unlock | |
| 28 | + | willNotify := false | |
| 29 | + | defer func() { | |
| 30 | + | if !willNotify { | |
| 31 | + | m.mu.Unlock() | |
| 32 | + | } | |
| 33 | + | }() | |
| 34 | + | ||
| 35 | + | // Skip reload if this was triggered by our own Save() - the in-memory | |
| 36 | + | // config is already correct and viper may have stale cached data | |
| 37 | + | if m.skipNextReload { | |
| 38 | + | log.Debug().Msg("skipping reload (triggered by own Save)") | |
| 39 | + | m.skipNextReload = false | |
| 40 | + | // Still need to sync viper's internal state with the file we just wrote | |
| 41 | + | if err := m.viper.ReadInConfig(); err != nil { | |
| 42 | + | log.Warn().Err(err).Msg("failed to sync viper config after Save") | |
| 43 | + | // Continue anyway - in-memory config is correct, just viper internal state is off | |
| 44 | + | } | |
| 45 | + | willNotify = true | |
| 46 | + | m.notifyCallbacksLocked() | |
| 24 | 47 | return | |
| 25 | 48 | } | |
| 26 | 49 | ||
| 27 | - | // Notify callbacks | |
| 28 | - | m.mu.RLock() | |
| 29 | - | config := m.config | |
| 30 | - | callbacks := make([]func(*Config), len(m.callbacks)) | |
| 31 | - | copy(callbacks, m.callbacks) | |
| 32 | - | m.mu.RUnlock() | |
| 33 | - | ||
| 34 | - | for _, callback := range callbacks { | |
| 35 | - | callback(config) | |
| 50 | + | log.Debug().Msg("reloading config from external change") | |
| 51 | + | if err := m.reload(); err != nil { | |
| 52 | + | log.Warn().Err(err).Msg("failed to reload config") | |
| 53 | + | return // defer will unlock | |
| 36 | 54 | } | |
| 55 | + | willNotify = true | |
| 56 | + | m.notifyCallbacksLocked() | |
| 37 | 57 | }) | |
| 38 | 58 | ||
| 39 | 59 | m.watching = true | |
| 40 | 60 | return nil | |
| 41 | 61 | } | |
| 42 | 62 | ||
| 63 | + | // notifyCallbacksLocked copies callbacks and config, releases lock, then notifies. | |
| 64 | + | // Must be called with m.mu held for write. Releases the lock before calling callbacks. | |
| 65 | + | func (m *Manager) notifyCallbacksLocked() { | |
| 66 | + | config := m.config | |
| 67 | + | callbacks := make([]func(*Config), len(m.callbacks)) | |
| 68 | + | copy(callbacks, m.callbacks) | |
| 69 | + | m.mu.Unlock() | |
| 70 | + | ||
| 71 | + | for _, callback := range callbacks { | |
| 72 | + | callback(config) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 43 | 76 | // OnConfigChange registers a callback function to be called when config changes. | |
| 44 | 77 | func (m *Manager) OnConfigChange(callback func(*Config)) { | |
| 45 | 78 | 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 | + | } | |
M internal/infrastructure/persistence/sqlite/history_repo.go +25 -4
| @@ -50,9 +50,14 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||
| 50 | 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 51 | 51 | log := logging.FromContext(ctx) | |
| 52 | 52 | ||
| 53 | + | // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators | |
| 54 | + | // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens | |
| 55 | + | // This allows searching "gordon.bne" to match URLs containing both terms | |
| 56 | + | normalizedQuery := strings.ReplaceAll(query, ".", " ") | |
| 57 | + | ||
| 53 | 58 | // Split query into words and add prefix matching to each | |
| 54 | 59 | // This enables multi-word searches like "github issues" -> "github* issues*" | |
| 55 | - | words := strings.Fields(query) | |
| 60 | + | words := strings.Fields(normalizedQuery) | |
| 56 | 61 | if len(words) == 0 { | |
| 57 | 62 | return []entity.HistoryMatch{}, nil | |
| 58 | 63 | } | |
| @@ -66,13 +71,16 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 66 | 71 | ftsQuery := strings.Join(parts, " ") | |
| 67 | 72 | ||
| 68 | 73 | // Search both URL and title columns, then merge results | |
| 69 | - | urlRows, urlErr := r.queries.SearchHistoryFTSUrl(ctx, sqlc.SearchHistoryFTSUrlParams{ | |
| 74 | + | // Use domain-boosted query for URL search to prioritize domain matches | |
| 75 | + | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | |
| 76 | + | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | |
| 77 | + | Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true}, | |
| 70 | 78 | Query: ftsQuery, | |
| 71 | 79 | Limit: int64(limit), | |
| 72 | 80 | }) | |
| 73 | 81 | if urlErr != nil { | |
| 74 | 82 | log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed") | |
| 75 | - | urlRows = []sqlc.History{} | |
| 83 | + | urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{} | |
| 76 | 84 | } | |
| 77 | 85 | ||
| 78 | 86 | titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{ | |
| @@ -97,7 +105,7 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 97 | 105 | if !seen[row.ID] { | |
| 98 | 106 | seen[row.ID] = true | |
| 99 | 107 | matches = append(matches, entity.HistoryMatch{ | |
| 100 | - | Entry: historyFromRow(row), | |
| 108 | + | Entry: historyFromDomainBoostRow(row), | |
| 101 | 109 | Score: 1.0, | |
| 102 | 110 | }) | |
| 103 | 111 | } | |
| @@ -120,8 +128,21 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 120 | 128 | return matches, nil | |
| 121 | 129 | } | |
| 122 | 130 | ||
| 131 | + | func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry { | |
| 132 | + | return &entity.HistoryEntry{ | |
| 133 | + | ID: row.ID, | |
| 134 | + | URL: row.Url, | |
| 135 | + | Title: row.Title.String, | |
| 136 | + | FaviconURL: row.FaviconUrl.String, | |
| 137 | + | VisitCount: row.VisitCount.Int64, | |
| 138 | + | LastVisited: row.LastVisited.Time, | |
| 139 | + | CreatedAt: row.CreatedAt.Time, | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 123 | 143 | // sanitizeFTS5Word removes FTS5 special characters from a search word. | |
| 124 | 144 | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | |
| 145 | + | // Note: Periods are handled earlier by replacing with spaces (tokenizer separator) | |
| 125 | 146 | func sanitizeFTS5Word(word string) string { | |
| 126 | 147 | // Remove characters that have special meaning in FTS5 | |
| 127 | 148 | var result strings.Builder | |
M internal/infrastructure/persistence/sqlite/history_repo_test.go +71 -0
| @@ -484,3 +484,74 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) { | |||
| 484 | 484 | assert.True(t, urls["https://github.com"]) | |
| 485 | 485 | assert.True(t, urls["https://gitlab.com"]) | |
| 486 | 486 | } | |
| 487 | + | ||
| 488 | + | func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) { | |
| 489 | + | ctx := historyTestCtx() | |
| 490 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 491 | + | ||
| 492 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 493 | + | require.NoError(t, err) | |
| 494 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 495 | + | ||
| 496 | + | repo := sqlite.NewHistoryRepository(db) | |
| 497 | + | ||
| 498 | + | // Save entries with domain-like URLs | |
| 499 | + | entries := []*entity.HistoryEntry{ | |
| 500 | + | {URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"}, | |
| 501 | + | {URL: "https://example.com", Title: "Example Site"}, | |
| 502 | + | {URL: "https://other.gordon.io", Title: "Another Gordon Site"}, | |
| 503 | + | } | |
| 504 | + | for _, e := range entries { | |
| 505 | + | require.NoError(t, repo.Save(ctx, e)) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | // Search with period in query (domain-like) - periods should be treated as separators | |
| 509 | + | // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema) | |
| 510 | + | results, err := repo.Search(ctx, "gordon.bnem", 10) | |
| 511 | + | require.NoError(t, err) | |
| 512 | + | require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev") | |
| 513 | + | ||
| 514 | + | // Verify the gordon.bnema.dev entry is found | |
| 515 | + | found := false | |
| 516 | + | for _, r := range results { | |
| 517 | + | if r.Entry.URL == "https://gordon.bnema.dev/" { | |
| 518 | + | found = true | |
| 519 | + | break | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | assert.True(t, found, "gordon.bnema.dev should be in results") | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |
| 526 | + | ctx := historyTestCtx() | |
| 527 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 528 | + | ||
| 529 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 530 | + | require.NoError(t, err) | |
| 531 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 532 | + | ||
| 533 | + | repo := sqlite.NewHistoryRepository(db) | |
| 534 | + | ||
| 535 | + | // Save entry | |
| 536 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 537 | + | URL: "https://example.com/path", | |
| 538 | + | Title: "Example Page", | |
| 539 | + | })) | |
| 540 | + | ||
| 541 | + | // Search with FTS5 special characters - should not cause errors | |
| 542 | + | specialQueries := []string{ | |
| 543 | + | "example:", // colon | |
| 544 | + | "example*", // asterisk | |
| 545 | + | "example()", // parentheses | |
| 546 | + | `example"test`, // quotes | |
| 547 | + | "example^", // caret | |
| 548 | + | "example-test", // hyphen | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | for _, q := range specialQueries { | |
| 552 | + | results, err := repo.Search(ctx, q, 10) | |
| 553 | + | require.NoError(t, err, "query %q should not cause error", q) | |
| 554 | + | // May or may not find results, but should not error | |
| 555 | + | _ = results | |
| 556 | + | } | |
| 557 | + | } | |
M internal/infrastructure/persistence/sqlite/queries/history.sql +15 -0
| @@ -57,6 +57,21 @@ WHERE fts.url MATCH @query | |||
| 57 | 57 | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 58 | 58 | LIMIT @limit; | |
| 59 | 59 | ||
| 60 | + | -- name: SearchHistoryFTSUrlWithDomainBoost :many | |
| 61 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, | |
| 62 | + | CASE | |
| 63 | + | WHEN h.url LIKE '%://' || @term || '.%' THEN 2 | |
| 64 | + | WHEN h.url LIKE '%://%.' || @term || '.%' THEN 2 | |
| 65 | + | WHEN h.url LIKE '%://' || @term || '/%' THEN 2 | |
| 66 | + | WHEN h.url LIKE '%://%.' || @term || '/%' THEN 1 | |
| 67 | + | ELSE 0 | |
| 68 | + | END as domain_boost | |
| 69 | + | FROM history_fts fts | |
| 70 | + | JOIN history h ON fts.rowid = h.id | |
| 71 | + | WHERE fts.url MATCH @query | |
| 72 | + | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | |
| 73 | + | LIMIT @limit; | |
| 74 | + | ||
| 60 | 75 | -- name: SearchHistoryFTSTitle :many | |
| 61 | 76 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| 62 | 77 | FROM history_fts fts | |
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +65 -0
| @@ -529,6 +529,71 @@ func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSU | |||
| 529 | 529 | return items, nil | |
| 530 | 530 | } | |
| 531 | 531 | ||
| 532 | + | const SearchHistoryFTSUrlWithDomainBoost = `-- name: SearchHistoryFTSUrlWithDomainBoost :many | |
| 533 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, | |
| 534 | + | CASE | |
| 535 | + | WHEN h.url LIKE '%://' || ?1 || '.%' THEN 2 | |
| 536 | + | WHEN h.url LIKE '%://%.' || ?1 || '.%' THEN 2 | |
| 537 | + | WHEN h.url LIKE '%://' || ?1 || '/%' THEN 2 | |
| 538 | + | WHEN h.url LIKE '%://%.' || ?1 || '/%' THEN 1 | |
| 539 | + | ELSE 0 | |
| 540 | + | END as domain_boost | |
| 541 | + | FROM history_fts fts | |
| 542 | + | JOIN history h ON fts.rowid = h.id | |
| 543 | + | WHERE fts.url MATCH ?2 | |
| 544 | + | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | |
| 545 | + | LIMIT ?3 | |
| 546 | + | ` | |
| 547 | + | ||
| 548 | + | type SearchHistoryFTSUrlWithDomainBoostParams struct { | |
| 549 | + | Term sql.NullString `json:"term"` | |
| 550 | + | Query string `json:"query"` | |
| 551 | + | Limit int64 `json:"limit"` | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | type SearchHistoryFTSUrlWithDomainBoostRow struct { | |
| 555 | + | ID int64 `json:"id"` | |
| 556 | + | Url string `json:"url"` | |
| 557 | + | Title sql.NullString `json:"title"` | |
| 558 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |
| 559 | + | VisitCount sql.NullInt64 `json:"visit_count"` | |
| 560 | + | LastVisited sql.NullTime `json:"last_visited"` | |
| 561 | + | CreatedAt sql.NullTime `json:"created_at"` | |
| 562 | + | DomainBoost int64 `json:"domain_boost"` | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) { | |
| 566 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost, arg.Term, arg.Query, arg.Limit) | |
| 567 | + | if err != nil { | |
| 568 | + | return nil, err | |
| 569 | + | } | |
| 570 | + | defer rows.Close() | |
| 571 | + | items := []SearchHistoryFTSUrlWithDomainBoostRow{} | |
| 572 | + | for rows.Next() { | |
| 573 | + | var i SearchHistoryFTSUrlWithDomainBoostRow | |
| 574 | + | if err := rows.Scan( | |
| 575 | + | &i.ID, | |
| 576 | + | &i.Url, | |
| 577 | + | &i.Title, | |
| 578 | + | &i.FaviconUrl, | |
| 579 | + | &i.VisitCount, | |
| 580 | + | &i.LastVisited, | |
| 581 | + | &i.CreatedAt, | |
| 582 | + | &i.DomainBoost, | |
| 583 | + | ); err != nil { | |
| 584 | + | return nil, err | |
| 585 | + | } | |
| 586 | + | items = append(items, i) | |
| 587 | + | } | |
| 588 | + | if err := rows.Close(); err != nil { | |
| 589 | + | return nil, err | |
| 590 | + | } | |
| 591 | + | if err := rows.Err(); err != nil { | |
| 592 | + | return nil, err | |
| 593 | + | } | |
| 594 | + | return items, nil | |
| 595 | + | } | |
| 596 | + | ||
| 532 | 597 | const UpsertHistory = `-- name: UpsertHistory :exec | |
| 533 | 598 | INSERT INTO history (url, title, favicon_url) | |
| 534 | 599 | VALUES (?, ?, ?) | |
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
| @@ -72,6 +72,7 @@ type Querier interface { | |||
| 72 | 72 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | |
| 73 | 73 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | |
| 74 | 74 | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | |
| 75 | + | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | |
| 75 | 76 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | |
| 76 | 77 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | |
| 77 | 78 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | |
M internal/infrastructure/webkit/download_handler.go +58 -44
| @@ -4,11 +4,11 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | - | "path/filepath" | |
| 8 | - | "strings" | |
| 9 | 7 | "sync" | |
| 10 | 8 | ||
| 11 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 12 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 13 | "github.com/bnema/puregotk-webkit/webkit" | |
| 14 | 14 | ) | |
| @@ -20,16 +20,26 @@ const ( | |||
| 20 | 20 | ||
| 21 | 21 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | |
| 22 | 22 | type DownloadHandler struct { | |
| 23 | - | downloadPath string | |
| 24 | - | eventHandler port.DownloadEventHandler | |
| 25 | - | mu sync.RWMutex | |
| 23 | + | downloadPath string | |
| 24 | + | eventHandler port.DownloadEventHandler | |
| 25 | + | prepareDownloadUC *usecase.PrepareDownloadUseCase | |
| 26 | + | mu sync.RWMutex | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | // NewDownloadHandler creates a new download handler. | |
| 29 | - | func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler { | |
| 30 | + | // Panics if prepareDownloadUC is nil (fail fast on misconfiguration). | |
| 31 | + | func NewDownloadHandler( | |
| 32 | + | downloadPath string, | |
| 33 | + | handler port.DownloadEventHandler, | |
| 34 | + | prepareDownloadUC *usecase.PrepareDownloadUseCase, | |
| 35 | + | ) *DownloadHandler { | |
| 36 | + | if prepareDownloadUC == nil { | |
| 37 | + | panic("prepareDownloadUC is required") | |
| 38 | + | } | |
| 30 | 39 | return &DownloadHandler{ | |
| 31 | - | downloadPath: downloadPath, | |
| 32 | - | eventHandler: handler, | |
| 40 | + | downloadPath: downloadPath, | |
| 41 | + | eventHandler: handler, | |
| 42 | + | prepareDownloadUC: prepareDownloadUC, | |
| 33 | 43 | } | |
| 34 | 44 | } | |
| 35 | 45 | ||
| @@ -47,6 +57,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 47 | 57 | h.mu.RLock() | |
| 48 | 58 | downloadPath := h.downloadPath | |
| 49 | 59 | eventHandler := h.eventHandler | |
| 60 | + | prepareDownloadUC := h.prepareDownloadUC | |
| 50 | 61 | h.mu.RUnlock() | |
| 51 | 62 | ||
| 52 | 63 | // Track download state in a dedicated struct to ensure proper isolation. | |
| @@ -63,31 +74,40 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 63 | 74 | return false | |
| 64 | 75 | } | |
| 65 | 76 | ||
| 66 | - | // Sanitize filename to prevent path traversal attacks. | |
| 67 | - | safeName := sanitizeFilename(suggestedFilename) | |
| 68 | - | destPath := filepath.Join(downloadPath, safeName) | |
| 77 | + | // Wrap WebKit response as port.DownloadResponse | |
| 78 | + | var response port.DownloadResponse | |
| 79 | + | if resp := d.GetResponse(); resp != nil { | |
| 80 | + | response = &uriResponseAdapter{resp: resp} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Use PrepareDownloadUseCase to resolve filename | |
| 84 | + | output := prepareDownloadUC.Execute(ctx, usecase.PrepareDownloadInput{ | |
| 85 | + | SuggestedFilename: suggestedFilename, | |
| 86 | + | Response: response, | |
| 87 | + | DownloadDir: downloadPath, | |
| 88 | + | }) | |
| 69 | 89 | ||
| 70 | 90 | log.Debug(). | |
| 71 | 91 | Str("suggested", suggestedFilename). | |
| 72 | - | Str("sanitized", safeName). | |
| 73 | - | Str("destPath", destPath). | |
| 92 | + | Str("sanitized", output.Filename). | |
| 93 | + | Str("destPath", output.DestinationPath). | |
| 74 | 94 | Msg("setting download destination") | |
| 75 | 95 | ||
| 76 | 96 | // WebKit expects an absolute file path for the destination. | |
| 77 | - | d.SetDestination(destPath) | |
| 97 | + | d.SetDestination(output.DestinationPath) | |
| 78 | 98 | ||
| 79 | 99 | // Notify: download started. | |
| 80 | 100 | if eventHandler != nil { | |
| 81 | 101 | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 82 | 102 | Type: port.DownloadEventStarted, | |
| 83 | - | Filename: safeName, | |
| 84 | - | Destination: destPath, | |
| 103 | + | Filename: output.Filename, | |
| 104 | + | Destination: output.DestinationPath, | |
| 85 | 105 | }) | |
| 86 | 106 | } | |
| 87 | 107 | ||
| 88 | 108 | log.Info(). | |
| 89 | - | Str("filename", safeName). | |
| 90 | - | Str("destination", destPath). | |
| 109 | + | Str("filename", output.Filename). | |
| 110 | + | Str("destination", output.DestinationPath). | |
| 91 | 111 | Msg("download started") | |
| 92 | 112 | ||
| 93 | 113 | return false // false = we handled it synchronously | |
| @@ -101,7 +121,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 101 | 121 | state.mu.Unlock() | |
| 102 | 122 | ||
| 103 | 123 | dest := d.GetDestination() | |
| 104 | - | filename := extractFilename(dest) | |
| 124 | + | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 105 | 125 | ||
| 106 | 126 | // Create error for the failed download. | |
| 107 | 127 | downloadErr := fmt.Errorf("download failed: %s", filename) | |
| @@ -131,7 +151,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 131 | 151 | } | |
| 132 | 152 | ||
| 133 | 153 | dest := d.GetDestination() | |
| 134 | - | filename := extractFilename(dest) | |
| 154 | + | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 135 | 155 | ||
| 136 | 156 | if eventHandler != nil { | |
| 137 | 157 | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| @@ -153,34 +173,28 @@ func (h *DownloadHandler) SetDownloadPath(path string) { | |||
| 153 | 173 | h.downloadPath = path | |
| 154 | 174 | } | |
| 155 | 175 | ||
| 156 | - | // extractFilename extracts the filename from a file:// URI or path. | |
| 157 | - | func extractFilename(dest string) string { | |
| 158 | - | // Remove file:// prefix if present. | |
| 159 | - | path := strings.TrimPrefix(dest, "file://") | |
| 160 | - | base := filepath.Base(path) | |
| 176 | + | // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse. | |
| 177 | + | type uriResponseAdapter struct { | |
| 178 | + | resp *webkit.URIResponse | |
| 179 | + | } | |
| 161 | 180 | ||
| 162 | - | // Handle edge cases consistently with sanitizeFilename. | |
| 163 | - | if base == "." || base == "" { | |
| 164 | - | return "download" | |
| 181 | + | func (a *uriResponseAdapter) GetMimeType() string { | |
| 182 | + | if a.resp == nil { | |
| 183 | + | return "" | |
| 165 | 184 | } | |
| 166 | - | return base | |
| 185 | + | return a.resp.GetMimeType() | |
| 167 | 186 | } | |
| 168 | 187 | ||
| 169 | - | // sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks. | |
| 170 | - | // It extracts only the base name and handles edge cases like "." or "..". | |
| 171 | - | func sanitizeFilename(name string) string { | |
| 172 | - | // Normalize Windows-style separators to forward slashes. | |
| 173 | - | // filepath.Base only handles the OS-native separator, so on Linux | |
| 174 | - | // backslashes would not be treated as path separators. | |
| 175 | - | name = strings.ReplaceAll(name, "\\", "/") | |
| 176 | - | ||
| 177 | - | // Get only the base name (removes any directory components). | |
| 178 | - | clean := filepath.Base(name) | |
| 179 | - | ||
| 180 | - | // If Base returns "." or ".." (edge cases), use fallback. | |
| 181 | - | if clean == "." || clean == ".." || clean == "" { | |
| 182 | - | return "download" | |
| 188 | + | func (a *uriResponseAdapter) GetSuggestedFilename() string { | |
| 189 | + | if a.resp == nil { | |
| 190 | + | return "" | |
| 183 | 191 | } | |
| 192 | + | return a.resp.GetSuggestedFilename() | |
| 193 | + | } | |
| 184 | 194 | ||
| 185 | - | return clean | |
| 195 | + | func (a *uriResponseAdapter) GetUri() string { | |
| 196 | + | if a.resp == nil { | |
| 197 | + | return "" | |
| 198 | + | } | |
| 199 | + | return a.resp.GetUri() | |
| 186 | 200 | } | |
M internal/infrastructure/webkit/download_handler_test.go +18 -120
| @@ -3,146 +3,44 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | - | func TestSanitizeFilename(t *testing.T) { | |
| 10 | - | tests := []struct { | |
| 11 | - | name string | |
| 12 | - | input string | |
| 13 | - | expected string | |
| 14 | - | }{ | |
| 15 | - | { | |
| 16 | - | name: "normal filename", | |
| 17 | - | input: "document.pdf", | |
| 18 | - | expected: "document.pdf", | |
| 19 | - | }, | |
| 20 | - | { | |
| 21 | - | name: "filename with spaces", | |
| 22 | - | input: "my document.pdf", | |
| 23 | - | expected: "my document.pdf", | |
| 24 | - | }, | |
| 25 | - | { | |
| 26 | - | name: "path traversal with parent dirs", | |
| 27 | - | input: "../../../etc/passwd", | |
| 28 | - | expected: "passwd", | |
| 29 | - | }, | |
| 30 | - | { | |
| 31 | - | name: "path traversal hidden file", | |
| 32 | - | input: "../.ssh/id_rsa", | |
| 33 | - | expected: "id_rsa", | |
| 34 | - | }, | |
| 35 | - | { | |
| 36 | - | name: "nested path", | |
| 37 | - | input: "foo/bar/baz.txt", | |
| 38 | - | expected: "baz.txt", | |
| 39 | - | }, | |
| 40 | - | { | |
| 41 | - | name: "absolute path", | |
| 42 | - | input: "/etc/passwd", | |
| 43 | - | expected: "passwd", | |
| 44 | - | }, | |
| 45 | - | { | |
| 46 | - | name: "dot only", | |
| 47 | - | input: ".", | |
| 48 | - | expected: "download", | |
| 49 | - | }, | |
| 50 | - | { | |
| 51 | - | name: "double dot only", | |
| 52 | - | input: "..", | |
| 53 | - | expected: "download", | |
| 54 | - | }, | |
| 55 | - | { | |
| 56 | - | name: "empty string", | |
| 57 | - | input: "", | |
| 58 | - | expected: "download", | |
| 59 | - | }, | |
| 60 | - | { | |
| 61 | - | name: "hidden file", | |
| 62 | - | input: ".bashrc", | |
| 63 | - | expected: ".bashrc", | |
| 64 | - | }, | |
| 65 | - | { | |
| 66 | - | name: "windows style path", | |
| 67 | - | input: "..\\..\\Windows\\System32\\config", | |
| 68 | - | expected: "config", | |
| 69 | - | }, | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | for _, tt := range tests { | |
| 73 | - | t.Run(tt.name, func(t *testing.T) { | |
| 74 | - | result := sanitizeFilename(tt.input) | |
| 75 | - | assert.Equal(t, tt.expected, result) | |
| 76 | - | }) | |
| 77 | - | } | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | func TestExtractFilename(t *testing.T) { | |
| 81 | - | tests := []struct { | |
| 82 | - | name string | |
| 83 | - | input string | |
| 84 | - | expected string | |
| 85 | - | }{ | |
| 86 | - | { | |
| 87 | - | name: "file URI with path", | |
| 88 | - | input: "file:///home/user/Downloads/document.pdf", | |
| 89 | - | expected: "document.pdf", | |
| 90 | - | }, | |
| 91 | - | { | |
| 92 | - | name: "file URI root", | |
| 93 | - | input: "file:///document.pdf", | |
| 94 | - | expected: "document.pdf", | |
| 95 | - | }, | |
| 96 | - | { | |
| 97 | - | name: "plain path", | |
| 98 | - | input: "/home/user/Downloads/image.png", | |
| 99 | - | expected: "image.png", | |
| 100 | - | }, | |
| 101 | - | { | |
| 102 | - | name: "filename only", | |
| 103 | - | input: "video.mp4", | |
| 104 | - | expected: "video.mp4", | |
| 105 | - | }, | |
| 106 | - | { | |
| 107 | - | name: "empty string", | |
| 108 | - | input: "", | |
| 109 | - | expected: "download", | |
| 110 | - | }, | |
| 111 | - | { | |
| 112 | - | name: "file URI with spaces", | |
| 113 | - | input: "file:///home/user/My Documents/file.txt", | |
| 114 | - | expected: "file.txt", | |
| 115 | - | }, | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | for _, tt := range tests { | |
| 119 | - | t.Run(tt.name, func(t *testing.T) { | |
| 120 | - | result := extractFilename(tt.input) | |
| 121 | - | assert.Equal(t, tt.expected, result) | |
| 122 | - | }) | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | 10 | func TestNewDownloadHandler(t *testing.T) { | |
| 11 | + | prepareUC := usecase.NewPrepareDownloadUseCase(nil) | |
| 12 | + | ||
| 127 | 13 | t.Run("creates handler with path and nil event handler", func(t *testing.T) { | |
| 128 | - | handler := NewDownloadHandler("/tmp/downloads", nil) | |
| 14 | + | handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC) | |
| 129 | 15 | ||
| 130 | 16 | assert.NotNil(t, handler) | |
| 131 | 17 | assert.Equal(t, "/tmp/downloads", handler.downloadPath) | |
| 132 | 18 | assert.Nil(t, handler.eventHandler) | |
| 19 | + | assert.NotNil(t, handler.prepareDownloadUC) | |
| 133 | 20 | }) | |
| 134 | 21 | ||
| 135 | 22 | t.Run("creates handler with custom path", func(t *testing.T) { | |
| 136 | - | handler := NewDownloadHandler("/custom/path", nil) | |
| 23 | + | handler := NewDownloadHandler("/custom/path", nil, prepareUC) | |
| 137 | 24 | ||
| 138 | 25 | assert.Equal(t, "/custom/path", handler.downloadPath) | |
| 139 | 26 | }) | |
| 140 | 27 | } | |
| 141 | 28 | ||
| 142 | 29 | func TestDownloadHandler_SetDownloadPath(t *testing.T) { | |
| 143 | - | handler := NewDownloadHandler("/initial/path", nil) | |
| 30 | + | prepareUC := usecase.NewPrepareDownloadUseCase(nil) | |
| 31 | + | handler := NewDownloadHandler("/initial/path", nil, prepareUC) | |
| 144 | 32 | ||
| 145 | 33 | handler.SetDownloadPath("/new/path") | |
| 146 | 34 | ||
| 147 | 35 | assert.Equal(t, "/new/path", handler.downloadPath) | |
| 148 | 36 | } | |
| 37 | + | ||
| 38 | + | func TestURIResponseAdapter(t *testing.T) { | |
| 39 | + | t.Run("handles nil response", func(t *testing.T) { | |
| 40 | + | adapter := &uriResponseAdapter{resp: nil} | |
| 41 | + | ||
| 42 | + | assert.Empty(t, adapter.GetMimeType()) | |
| 43 | + | assert.Empty(t, adapter.GetSuggestedFilename()) | |
| 44 | + | assert.Empty(t, adapter.GetUri()) | |
| 45 | + | }) | |
| 46 | + | } | |
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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -36,6 +37,15 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||
| 36 | 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 | 49 | // Clipboard handlers (for auto-copy on selection feature) | |
| 40 | 50 | if cfg.Clipboard != nil && cfg.ConfigGetter != nil { | |
| 41 | 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 | 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 | 174 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | |
| 175 | 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 | 179 | if valuePtr == 0 { | |
| 178 | 180 | log.Warn().Msg("received script message with nil value pointer") | |
| 179 | 181 | return | |
| @@ -186,8 +188,14 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 186 | 188 | } | |
| 187 | 189 | ||
| 188 | 190 | rawJSON := jscValue.ToJson(0) | |
| 191 | + | log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result") | |
| 189 | 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 | 199 | return | |
| 192 | 200 | } | |
| 193 | 201 | ||
| @@ -213,7 +221,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 213 | 221 | ||
| 214 | 222 | entry, ok := r.getHandler(msg.Type) | |
| 215 | 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 | 231 | return | |
| 218 | 232 | } | |
| 219 | 233 | ||
| @@ -221,9 +235,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 221 | 235 | Str("type", msg.Type). | |
| 222 | 236 | Uint64("webview_id", msg.WebViewID). | |
| 223 | 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 | 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 | 243 | if err != nil { | |
| 228 | 244 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | |
| 229 | 245 | if entry.errorCallback != "" { | |
M internal/infrastructure/webkit/webview.go +145 -41
| @@ -3,6 +3,8 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "net/url" | |
| 7 | + | "strings" | |
| 6 | 8 | "sync" | |
| 7 | 9 | "sync/atomic" | |
| 8 | 10 | "time" | |
| @@ -552,60 +554,156 @@ func (wv *WebView) connectProgressSignal() { | |||
| 552 | 554 | ||
| 553 | 555 | func (wv *WebView) connectDecidePolicySignal() { | |
| 554 | 556 | decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool { | |
| 555 | - | if decisionType != webkit.PolicyDecisionTypeNavigationActionValue { | |
| 556 | - | return false // Let WebKit handle | |
| 557 | - | } | |
| 558 | - | ||
| 559 | - | navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr) | |
| 560 | - | if navDecision == nil { | |
| 557 | + | switch decisionType { | |
| 558 | + | case webkit.PolicyDecisionTypeResponseValue: | |
| 559 | + | return wv.handleResponsePolicyDecision(decisionPtr) | |
| 560 | + | case webkit.PolicyDecisionTypeNavigationActionValue: | |
| 561 | + | return wv.handleNavigationPolicyDecision(decisionPtr) | |
| 562 | + | default: | |
| 561 | 563 | return false | |
| 562 | 564 | } | |
| 565 | + | } | |
| 566 | + | sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb) | |
| 567 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 568 | + | } | |
| 563 | 569 | ||
| 564 | - | navAction := navDecision.GetNavigationAction() | |
| 565 | - | if navAction == nil { | |
| 566 | - | return false | |
| 567 | - | } | |
| 570 | + | // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads). | |
| 571 | + | func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool { | |
| 572 | + | responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr) | |
| 573 | + | if responseDecision == nil { | |
| 574 | + | return false | |
| 575 | + | } | |
| 568 | 576 | ||
| 569 | - | if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue { | |
| 570 | - | return false | |
| 571 | - | } | |
| 577 | + | if !responseDecision.IsMainFrameMainResource() { | |
| 578 | + | return false | |
| 579 | + | } | |
| 572 | 580 | ||
| 573 | - | mouseButton := navAction.GetMouseButton() | |
| 574 | - | modifiers := navAction.GetModifiers() | |
| 575 | - | isMiddleClick := mouseButton == 2 | |
| 576 | - | isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0 | |
| 581 | + | if !shouldForceDownload(responseDecision) { | |
| 582 | + | return false | |
| 583 | + | } | |
| 577 | 584 | ||
| 578 | - | if !isMiddleClick && !isCtrlClick { | |
| 579 | - | return false | |
| 580 | - | } | |
| 585 | + | policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr) | |
| 586 | + | if policyDecision == nil { | |
| 587 | + | return false | |
| 588 | + | } | |
| 581 | 589 | ||
| 582 | - | request := navAction.GetRequest() | |
| 583 | - | if request == nil { | |
| 584 | - | return false | |
| 585 | - | } | |
| 590 | + | response := responseDecision.GetResponse() | |
| 591 | + | mimeType := "" | |
| 592 | + | uri := "" | |
| 593 | + | if response != nil { | |
| 594 | + | mimeType = response.GetMimeType() | |
| 595 | + | uri = response.GetUri() | |
| 596 | + | } | |
| 586 | 597 | ||
| 587 | - | linkURI := request.GetUri() | |
| 588 | - | if linkURI == "" { | |
| 589 | - | return false | |
| 590 | - | } | |
| 598 | + | wv.logger.Debug(). | |
| 599 | + | Str("mime_type", mimeType). | |
| 600 | + | Str("uri", uri). | |
| 601 | + | Msg("forcing download for response") | |
| 602 | + | ||
| 603 | + | // Start a fresh download instead of converting the in-progress response. | |
| 604 | + | // Using policyDecision.Download() can fail due to race conditions where | |
| 605 | + | // the network connection is released before the download can use it. | |
| 606 | + | // By ignoring the policy and starting a new download, we avoid this issue. | |
| 607 | + | // We defer the download to the next idle cycle to let WebKit clean up | |
| 608 | + | // the ignored navigation first. | |
| 609 | + | if uri != "" { | |
| 610 | + | policyDecision.Ignore() | |
| 611 | + | downloadURI := uri | |
| 612 | + | inner := wv.inner | |
| 613 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 614 | + | inner.DownloadUri(downloadURI) | |
| 615 | + | return false // Don't repeat | |
| 616 | + | }) | |
| 617 | + | // Store callback reference to prevent GC before GTK calls it | |
| 618 | + | wv.mu.Lock() | |
| 619 | + | wv.asyncCallbacks = append(wv.asyncCallbacks, &cb) | |
| 620 | + | wv.mu.Unlock() | |
| 621 | + | glib.IdleAdd(&cb, 0) | |
| 622 | + | return true | |
| 623 | + | } | |
| 591 | 624 | ||
| 592 | - | wv.logger.Debug(). | |
| 593 | - | Str("uri", linkURI). | |
| 594 | - | Uint("button", mouseButton). | |
| 595 | - | Bool("ctrl", isCtrlClick). | |
| 596 | - | Msg("middle-click/ctrl+click on link detected") | |
| 597 | - | ||
| 598 | - | if wv.OnLinkMiddleClick != nil { | |
| 599 | - | if wv.OnLinkMiddleClick(linkURI) { | |
| 600 | - | navDecision.Ignore() | |
| 601 | - | return true | |
| 602 | - | } | |
| 625 | + | // Fallback to old behavior if URI is not available | |
| 626 | + | policyDecision.Download() | |
| 627 | + | return true | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click). | |
| 631 | + | func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool { | |
| 632 | + | navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr) | |
| 633 | + | if navDecision == nil { | |
| 634 | + | return false | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | navAction := navDecision.GetNavigationAction() | |
| 638 | + | if navAction == nil { | |
| 639 | + | return false | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue { | |
| 643 | + | return false | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | mouseButton := navAction.GetMouseButton() | |
| 647 | + | modifiers := navAction.GetModifiers() | |
| 648 | + | isMiddleClick := mouseButton == 2 | |
| 649 | + | isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0 | |
| 650 | + | ||
| 651 | + | if !isMiddleClick && !isCtrlClick { | |
| 652 | + | return false | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | request := navAction.GetRequest() | |
| 656 | + | if request == nil { | |
| 657 | + | return false | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | linkURI := request.GetUri() | |
| 661 | + | if linkURI == "" { | |
| 662 | + | return false | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | wv.logger.Debug(). | |
| 666 | + | Str("uri", linkURI). | |
| 667 | + | Uint("button", mouseButton). | |
| 668 | + | Bool("ctrl", isCtrlClick). | |
| 669 | + | Msg("middle-click/ctrl+click on link detected") | |
| 670 | + | ||
| 671 | + | if wv.OnLinkMiddleClick != nil { | |
| 672 | + | if wv.OnLinkMiddleClick(linkURI) { | |
| 673 | + | navDecision.Ignore() | |
| 674 | + | return true | |
| 603 | 675 | } | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | return false | |
| 679 | + | } | |
| 604 | 680 | ||
| 681 | + | func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool { | |
| 682 | + | if responseDecision == nil { | |
| 605 | 683 | return false | |
| 606 | 684 | } | |
| 607 | - | sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb) | |
| 608 | - | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 685 | + | ||
| 686 | + | response := responseDecision.GetResponse() | |
| 687 | + | if response == nil { | |
| 688 | + | return false | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | mimeType := strings.ToLower(response.GetMimeType()) | |
| 692 | + | if strings.HasPrefix(mimeType, "application/pdf") { | |
| 693 | + | return true | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | uri := response.GetUri() | |
| 697 | + | if uri == "" { | |
| 698 | + | return false | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | parsed, err := url.Parse(uri) | |
| 702 | + | if err != nil { | |
| 703 | + | return strings.Contains(strings.ToLower(uri), ".pdf") | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf") | |
| 609 | 707 | } | |
| 610 | 708 | ||
| 611 | 709 | func (wv *WebView) connectEnterFullscreenSignal() { | |
| @@ -905,6 +1003,12 @@ func (wv *WebView) SetBackgroundColor(r, g, b, a float32) { | |||
| 905 | 1003 | wv.inner.SetBackgroundColor(rgba) | |
| 906 | 1004 | } | |
| 907 | 1005 | ||
| 1006 | + | // ResetBackgroundToDefault sets WebView background to white (browser default). | |
| 1007 | + | // Used for external pages to prevent dark background from bleeding through. | |
| 1008 | + | func (wv *WebView) ResetBackgroundToDefault() { | |
| 1009 | + | wv.SetBackgroundColor(1.0, 1.0, 1.0, 1.0) | |
| 1010 | + | } | |
| 1011 | + | ||
| 908 | 1012 | // Show makes the WebView widget visible. | |
| 909 | 1013 | // This should be called after the WebView is ready to be displayed. | |
| 910 | 1014 | func (wv *WebView) Show() { | |
M internal/ui/app.go +14 -2
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 18 | 19 | "github.com/bnema/dumber/internal/infrastructure/textinput" | |
| @@ -495,8 +496,11 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||
| 495 | 496 | // Create download event adapter to show toasts. | |
| 496 | 497 | eventAdapter := &downloadEventAdapter{app: a} | |
| 497 | 498 | ||
| 499 | + | // Create use case for preparing download destinations with file deduplication. | |
| 500 | + | prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New()) | |
| 501 | + | ||
| 498 | 502 | // Create and wire the download handler. | |
| 499 | - | handler := webkit.NewDownloadHandler(downloadPath, eventAdapter) | |
| 503 | + | handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC) | |
| 500 | 504 | a.deps.WebContext.SetDownloadHandler(ctx, handler) | |
| 501 | 505 | ||
| 502 | 506 | log.Info().Str("path", downloadPath).Msg("download handler initialized") | |
| @@ -590,6 +594,9 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 590 | 594 | } | |
| 591 | 595 | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |
| 592 | 596 | ||
| 597 | + | // Create autocomplete use case for inline suggestions | |
| 598 | + | autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC) | |
| 599 | + | ||
| 593 | 600 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | |
| 594 | 601 | a.omniboxCfg = component.OmniboxConfig{ | |
| 595 | 602 | HistoryUC: a.deps.HistoryUC, | |
| @@ -597,6 +604,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 597 | 604 | FaviconAdapter: a.faviconAdapter, | |
| 598 | 605 | CopyURLUC: a.deps.CopyURLUC, | |
| 599 | 606 | ShortcutsUC: shortcutsUC, | |
| 607 | + | AutocompleteUC: autocompleteUC, | |
| 600 | 608 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | |
| 601 | 609 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | |
| 602 | 610 | UIScale: a.deps.Config.DefaultUIScale, | |
| @@ -1942,11 +1950,15 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 1942 | 1950 | return | |
| 1943 | 1951 | } | |
| 1944 | 1952 | ||
| 1945 | - | // Only appearance is hot-reloaded for now. | |
| 1953 | + | // Hot-reload appearance and keybindings on config change. | |
| 1946 | 1954 | a.configManager.OnConfigChange(func(newCfg *config.Config) { | |
| 1947 | 1955 | cfgCopy := newCfg | |
| 1948 | 1956 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1949 | 1957 | a.applyAppearanceConfig(ctx, cfgCopy) | |
| 1958 | + | // Reload keyboard shortcuts | |
| 1959 | + | if a.keyboardHandler != nil { | |
| 1960 | + | a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy) | |
| 1961 | + | } | |
| 1950 | 1962 | return false | |
| 1951 | 1963 | }) | |
| 1952 | 1964 | glib.IdleAdd(&cb, 0) | |
M internal/ui/component/omnibox.go +517 -43
| @@ -3,11 +3,14 @@ package component | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| 10 | + | "unicode/utf8" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/autocomplete" | |
| 11 | 14 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 15 | "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 16 | "github.com/bnema/dumber/internal/logging" | |
| @@ -16,11 +19,13 @@ import ( | |||
| 16 | 19 | "github.com/bnema/dumber/internal/ui/layout" | |
| 17 | 20 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 18 | 21 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 22 | + | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| 19 | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 24 | + | "github.com/jwijenbergh/puregotk/v4/pango" | |
| 20 | 25 | ) | |
| 21 | 26 | ||
| 22 | 27 | const ( | |
| 23 | - | debounceDelayMs = 150 | |
| 28 | + | debounceDelayMs = 50 | |
| 24 | 29 | endBoxSpacing = 6 | |
| 25 | 30 | ) | |
| 26 | 31 | ||
| @@ -67,6 +72,8 @@ type Omnibox struct { | |||
| 67 | 72 | zoomLabel *gtk.Label | |
| 68 | 73 | bangBadge *gtk.Label | |
| 69 | 74 | entry *gtk.SearchEntry | |
| 75 | + | entryOverlay *gtk.Overlay // Overlay for ghost text | |
| 76 | + | ghostLabel *gtk.Label // Ghost text completion label | |
| 70 | 77 | scrolledWin *gtk.ScrolledWindow | |
| 71 | 78 | listBox *gtk.ListBox | |
| 72 | 79 | ||
| @@ -85,12 +92,20 @@ type Omnibox struct { | |||
| 85 | 92 | detectedBang string | |
| 86 | 93 | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |
| 87 | 94 | ||
| 95 | + | // Ghost text state | |
| 96 | + | realInput string // What user actually typed (without ghost suffix) | |
| 97 | + | ghostSuffix string // Completion suffix to display | |
| 98 | + | ghostFullText string // Full text that ghost completes to | |
| 99 | + | hasGhostText bool // Quick check for Tab behavior | |
| 100 | + | isAcceptingGhost bool // Prevent re-trigger during acceptance | |
| 101 | + | ||
| 88 | 102 | // Dependencies | |
| 89 | 103 | historyUC *usecase.SearchHistoryUseCase | |
| 90 | 104 | favoritesUC *usecase.ManageFavoritesUseCase | |
| 91 | 105 | faviconAdapter *adapter.FaviconAdapter | |
| 92 | 106 | copyURLUC *usecase.CopyURLUseCase | |
| 93 | 107 | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 108 | + | autocompleteUC *usecase.AutocompleteUseCase | |
| 94 | 109 | defaultSearch string | |
| 95 | 110 | initialBehavior string | |
| 96 | 111 | ctx context.Context | |
| @@ -118,6 +133,10 @@ type Omnibox struct { | |||
| 118 | 133 | ||
| 119 | 134 | // Callback retention: keep GTK signal callbacks reachable by Go GC. | |
| 120 | 135 | retainedCallbacks []interface{} | |
| 136 | + | ||
| 137 | + | // Click outside handler | |
| 138 | + | clickOutsideController *gtk.GestureClick | |
| 139 | + | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | |
| 121 | 140 | } | |
| 122 | 141 | ||
| 123 | 142 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| @@ -127,6 +146,7 @@ type OmniboxConfig struct { | |||
| 127 | 146 | FaviconAdapter *adapter.FaviconAdapter | |
| 128 | 147 | CopyURLUC *usecase.CopyURLUseCase | |
| 129 | 148 | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 149 | + | AutocompleteUC *usecase.AutocompleteUseCase | |
| 130 | 150 | DefaultSearch string | |
| 131 | 151 | InitialBehavior string | |
| 132 | 152 | UIScale float64 // UI scale for favicon sizing | |
| @@ -154,6 +174,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 154 | 174 | faviconAdapter: cfg.FaviconAdapter, | |
| 155 | 175 | copyURLUC: cfg.CopyURLUC, | |
| 156 | 176 | shortcutsUC: cfg.ShortcutsUC, | |
| 177 | + | autocompleteUC: cfg.AutocompleteUC, | |
| 157 | 178 | defaultSearch: cfg.DefaultSearch, | |
| 158 | 179 | initialBehavior: cfg.InitialBehavior, | |
| 159 | 180 | onToast: cfg.OnToast, | |
| @@ -185,6 +206,86 @@ func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) { | |||
| 185 | 206 | o.mu.Lock() | |
| 186 | 207 | defer o.mu.Unlock() | |
| 187 | 208 | o.parentOverlay = overlay | |
| 209 | + | o.setupClickOutsideHandler() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // setupClickOutsideHandler attaches a click gesture to the parent overlay | |
| 213 | + | // that hides the omnibox when clicking outside its bounds. | |
| 214 | + | func (o *Omnibox) setupClickOutsideHandler() { | |
| 215 | + | if o.parentOverlay == nil { | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | log := logging.FromContext(o.ctx) | |
| 220 | + | ||
| 221 | + | // Create gesture click controller | |
| 222 | + | o.clickOutsideController = gtk.NewGestureClick() | |
| 223 | + | if o.clickOutsideController == nil { | |
| 224 | + | log.Error().Msg("failed to create click outside controller") | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // Listen to primary button only | |
| 229 | + | o.clickOutsideController.SetButton(1) // GDK_BUTTON_PRIMARY | |
| 230 | + | ||
| 231 | + | // Set capture phase so we see clicks before they reach the omnibox | |
| 232 | + | o.clickOutsideController.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 233 | + | ||
| 234 | + | // Connect pressed handler | |
| 235 | + | o.clickOutsideCb = func(_ gtk.GestureClick, _ int, x, y float64) { | |
| 236 | + | o.handleClickOutside(x, y) | |
| 237 | + | } | |
| 238 | + | o.clickOutsideController.ConnectPressed(&o.clickOutsideCb) | |
| 239 | + | ||
| 240 | + | // Add controller to parent overlay | |
| 241 | + | gtkWidget := o.parentOverlay.GtkWidget() | |
| 242 | + | if gtkWidget != nil { | |
| 243 | + | gtkWidget.AddController(&o.clickOutsideController.EventController) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | log.Debug().Msg("click outside handler attached to parent overlay") | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // handleClickOutside checks if a click is outside the omnibox bounds and hides if so. | |
| 250 | + | func (o *Omnibox) handleClickOutside(clickX, clickY float64) { | |
| 251 | + | o.mu.RLock() | |
| 252 | + | visible := o.visible | |
| 253 | + | o.mu.RUnlock() | |
| 254 | + | ||
| 255 | + | if !visible || o.outerBox == nil || o.parentOverlay == nil { | |
| 256 | + | return | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Get omnibox position relative to parent overlay using graphene points | |
| 260 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 261 | + | outPoint := &graphene.Point{} | |
| 262 | + | ok := o.outerBox.ComputePoint(o.parentOverlay.GtkWidget(), srcPoint, outPoint) | |
| 263 | + | if !ok { | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | boxX := float64(outPoint.X) | |
| 268 | + | boxY := float64(outPoint.Y) | |
| 269 | + | ||
| 270 | + | // Get omnibox dimensions | |
| 271 | + | boxWidth := float64(o.outerBox.GetAllocatedWidth()) | |
| 272 | + | boxHeight := float64(o.outerBox.GetAllocatedHeight()) | |
| 273 | + | ||
| 274 | + | // Check if click is inside omnibox bounds | |
| 275 | + | isInside := clickX >= boxX && clickX <= boxX+boxWidth && | |
| 276 | + | clickY >= boxY && clickY <= boxY+boxHeight | |
| 277 | + | ||
| 278 | + | if !isInside { | |
| 279 | + | logging.FromContext(o.ctx).Debug(). | |
| 280 | + | Float64("clickX", clickX). | |
| 281 | + | Float64("clickY", clickY). | |
| 282 | + | Float64("boxX", boxX). | |
| 283 | + | Float64("boxY", boxY). | |
| 284 | + | Float64("boxWidth", boxWidth). | |
| 285 | + | Float64("boxHeight", boxHeight). | |
| 286 | + | Msg("click outside omnibox, hiding") | |
| 287 | + | o.Hide(o.ctx) | |
| 288 | + | } | |
| 188 | 289 | } | |
| 189 | 290 | ||
| 190 | 291 | // WidgetAsLayout returns the omnibox's outer widget as a layout.Widget. | |
| @@ -389,6 +490,31 @@ func (o *Omnibox) initEntry() error { | |||
| 389 | 490 | ||
| 390 | 491 | placeholder := "Search history or enter URL… (! lists bangs)" | |
| 391 | 492 | o.entry.SetPlaceholderText(&placeholder) | |
| 493 | + | ||
| 494 | + | // Create overlay for ghost text | |
| 495 | + | o.entryOverlay = gtk.NewOverlay() | |
| 496 | + | if o.entryOverlay == nil { | |
| 497 | + | return errNilWidget("entryOverlay") | |
| 498 | + | } | |
| 499 | + | o.entryOverlay.SetChild(&o.entry.Widget) | |
| 500 | + | o.entryOverlay.SetHexpand(true) | |
| 501 | + | ||
| 502 | + | // Create ghost label for autocomplete suffix | |
| 503 | + | o.ghostLabel = gtk.NewLabel(nil) | |
| 504 | + | if o.ghostLabel == nil { | |
| 505 | + | return errNilWidget("ghostLabel") | |
| 506 | + | } | |
| 507 | + | o.ghostLabel.AddCssClass("omnibox-ghost") | |
| 508 | + | o.ghostLabel.SetHalign(gtk.AlignStartValue) | |
| 509 | + | o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text | |
| 510 | + | o.ghostLabel.SetXalign(0) | |
| 511 | + | o.ghostLabel.SetVisible(false) | |
| 512 | + | o.ghostLabel.SetCanTarget(false) // Don't intercept clicks | |
| 513 | + | o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long | |
| 514 | + | ||
| 515 | + | o.entryOverlay.AddOverlay(&o.ghostLabel.Widget) | |
| 516 | + | o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds | |
| 517 | + | ||
| 392 | 518 | return nil | |
| 393 | 519 | } | |
| 394 | 520 | ||
| @@ -425,6 +551,48 @@ func (o *Omnibox) initList() error { | |||
| 425 | 551 | } | |
| 426 | 552 | o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb) | |
| 427 | 553 | o.listBox.ConnectRowSelected(&rowSelectedCb) | |
| 554 | + | ||
| 555 | + | // Handle row activation (click or Enter) - navigate directly to the URL | |
| 556 | + | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 557 | + | if rowPtr == 0 { | |
| 558 | + | return | |
| 559 | + | } | |
| 560 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 561 | + | if row == nil { | |
| 562 | + | return | |
| 563 | + | } | |
| 564 | + | idx := row.GetIndex() | |
| 565 | + | o.mu.RLock() | |
| 566 | + | mode := o.viewMode | |
| 567 | + | bangMode := o.bangMode | |
| 568 | + | suggestions := o.suggestions | |
| 569 | + | favorites := o.favorites | |
| 570 | + | o.mu.RUnlock() | |
| 571 | + | ||
| 572 | + | // Don't navigate in bang mode - activating a bang should fill it in | |
| 573 | + | if bangMode { | |
| 574 | + | return | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | var targetURL string | |
| 578 | + | if mode == ViewModeHistory { | |
| 579 | + | if idx >= 0 && idx < len(suggestions) { | |
| 580 | + | targetURL = suggestions[idx].URL | |
| 581 | + | } | |
| 582 | + | } else { | |
| 583 | + | if idx >= 0 && idx < len(favorites) { | |
| 584 | + | targetURL = favorites[idx].URL | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | if targetURL != "" && o.onNavigate != nil { | |
| 589 | + | o.Hide(o.ctx) | |
| 590 | + | o.onNavigate(targetURL) | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb) | |
| 594 | + | o.listBox.ConnectRowActivated(&rowActivatedCb) | |
| 595 | + | ||
| 428 | 596 | return nil | |
| 429 | 597 | } | |
| 430 | 598 | ||
| @@ -447,7 +615,7 @@ func (o *Omnibox) assembleWidgets() { | |||
| 447 | 615 | ||
| 448 | 616 | o.scrolledWin.SetChild(&o.listBox.Widget) | |
| 449 | 617 | o.mainBox.Append(&o.headerBox.Widget) | |
| 450 | - | o.mainBox.Append(&o.entry.Widget) | |
| 618 | + | o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support | |
| 451 | 619 | o.mainBox.Append(&o.scrolledWin.Widget) | |
| 452 | 620 | o.outerBox.Append(&o.mainBox.Widget) | |
| 453 | 621 | } | |
| @@ -493,6 +661,20 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO | |||
| 493 | 661 | o.entry.AddController(&focusController.EventController) | |
| 494 | 662 | } | |
| 495 | 663 | ||
| 664 | + | // hasGhost returns whether ghost text is currently visible. | |
| 665 | + | func (o *Omnibox) hasGhost() bool { | |
| 666 | + | o.mu.RLock() | |
| 667 | + | defer o.mu.RUnlock() | |
| 668 | + | return o.hasGhostText | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | // hasUserNavigated returns whether user has navigated with arrow keys. | |
| 672 | + | func (o *Omnibox) hasUserNavigated() bool { | |
| 673 | + | o.mu.RLock() | |
| 674 | + | defer o.mu.RUnlock() | |
| 675 | + | return o.hasNavigated | |
| 676 | + | } | |
| 677 | + | ||
| 496 | 678 | // handleKeyPress processes keyboard events. | |
| 497 | 679 | // Returns true if the event was handled. | |
| 498 | 680 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| @@ -521,23 +703,34 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 521 | 703 | ||
| 522 | 704 | case uint(gdk.KEY_Up): | |
| 523 | 705 | o.selectPrevious() | |
| 706 | + | o.updateGhostFromSelection() | |
| 524 | 707 | return true | |
| 525 | 708 | ||
| 526 | 709 | case uint(gdk.KEY_Down): | |
| 527 | 710 | o.selectNext() | |
| 711 | + | o.updateGhostFromSelection() | |
| 528 | 712 | return true | |
| 529 | 713 | ||
| 530 | 714 | case uint(gdk.KEY_Tab): | |
| 715 | + | // Tab accepts ghost text if present, otherwise toggles view mode | |
| 716 | + | if o.hasGhost() { | |
| 717 | + | o.acceptGhostCompletion() | |
| 718 | + | return true | |
| 719 | + | } | |
| 531 | 720 | o.toggleViewMode() | |
| 532 | 721 | return true | |
| 533 | 722 | ||
| 723 | + | case uint(gdk.KEY_Right): | |
| 724 | + | // Arrow Right accepts ghost text if present | |
| 725 | + | if o.hasGhost() { | |
| 726 | + | o.acceptGhostCompletion() | |
| 727 | + | return true | |
| 728 | + | } | |
| 729 | + | return false // Let entry handle normal cursor movement | |
| 730 | + | ||
| 534 | 731 | case uint(gdk.KEY_space): | |
| 535 | 732 | // Space toggles favorite only if user has navigated with arrow keys | |
| 536 | - | // Otherwise, let space pass through to entry for typing | |
| 537 | - | o.mu.RLock() | |
| 538 | - | navigated := o.hasNavigated | |
| 539 | - | o.mu.RUnlock() | |
| 540 | - | if navigated { | |
| 733 | + | if o.hasUserNavigated() { | |
| 541 | 734 | o.toggleFavorite() | |
| 542 | 735 | return true | |
| 543 | 736 | } | |
| @@ -545,10 +738,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 545 | 738 | ||
| 546 | 739 | case uint(gdk.KEY_y): | |
| 547 | 740 | // 'y' yanks (copies) the selected URL to clipboard when navigating | |
| 548 | - | o.mu.RLock() | |
| 549 | - | navigated := o.hasNavigated | |
| 550 | - | o.mu.RUnlock() | |
| 551 | - | if navigated { | |
| 741 | + | if o.hasUserNavigated() { | |
| 552 | 742 | o.yankSelectedURL() | |
| 553 | 743 | return true | |
| 554 | 744 | } | |
| @@ -599,11 +789,24 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool | |||
| 599 | 789 | ||
| 600 | 790 | // onEntryChanged handles text input changes with debouncing. | |
| 601 | 791 | func (o *Omnibox) onEntryChanged() { | |
| 792 | + | // Skip if we're programmatically accepting ghost text | |
| 793 | + | o.mu.RLock() | |
| 794 | + | isAccepting := o.isAcceptingGhost | |
| 795 | + | o.mu.RUnlock() | |
| 796 | + | if isAccepting { | |
| 797 | + | return | |
| 798 | + | } | |
| 799 | + | ||
| 602 | 800 | // Reset navigation state when user types - space should type, not toggle favorite | |
| 603 | 801 | o.mu.Lock() | |
| 604 | 802 | o.hasNavigated = false | |
| 803 | + | // Update realInput with what user actually typed | |
| 804 | + | o.realInput = o.entry.GetText() | |
| 605 | 805 | o.mu.Unlock() | |
| 606 | 806 | ||
| 807 | + | // Clear ghost text when user types (it will be recalculated after search) | |
| 808 | + | o.clearGhostText() | |
| 809 | + | ||
| 607 | 810 | o.debounceMu.Lock() | |
| 608 | 811 | if o.debounceTimer != nil { | |
| 609 | 812 | o.debounceTimer.Stop() | |
| @@ -614,6 +817,252 @@ func (o *Omnibox) onEntryChanged() { | |||
| 614 | 817 | o.debounceMu.Unlock() | |
| 615 | 818 | } | |
| 616 | 819 | ||
| 820 | + | // setGhostText displays a ghost completion suffix in the overlay label. | |
| 821 | + | // The ghost text shows what would complete the current input. | |
| 822 | + | // originalInput is the input that was used for the autocomplete query. | |
| 823 | + | // If originalInput is empty, the ghost text replaces the entire input (used for row selection). | |
| 824 | + | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |
| 825 | + | if o.ghostLabel == nil || suffix == "" { | |
| 826 | + | o.clearGhostText() | |
| 827 | + | return | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | // Store ghost state optimistically - will be validated on main thread | |
| 831 | + | o.mu.Lock() | |
| 832 | + | o.ghostSuffix = suffix | |
| 833 | + | o.ghostFullText = fullText | |
| 834 | + | o.hasGhostText = true | |
| 835 | + | o.mu.Unlock() | |
| 836 | + | ||
| 837 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 838 | + | if o.ghostLabel == nil || o.entry == nil { | |
| 839 | + | return false | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | // Check if input has changed since the autocomplete query was made | |
| 843 | + | // This check runs on GTK main thread where entry.GetText() is reliable | |
| 844 | + | // Skip this check when originalInput is empty (row selection replaces full input) | |
| 845 | + | if originalInput != "" && o.entry.GetText() != originalInput { | |
| 846 | + | // Input changed, skip this stale result - a newer query will handle it | |
| 847 | + | return false | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | // Get the entry's Pango context and create a layout to measure text width | |
| 851 | + | pangoCtx := o.entry.GetPangoContext() | |
| 852 | + | if pangoCtx == nil { | |
| 853 | + | return false | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | // Create layout to measure the input text width | |
| 857 | + | layout := pango.NewLayout(pangoCtx) | |
| 858 | + | layout.SetText(originalInput, len(originalInput)) | |
| 859 | + | ||
| 860 | + | // Get the width in pixels | |
| 861 | + | var widthPx, heightPx int | |
| 862 | + | layout.GetPixelSize(&widthPx, &heightPx) | |
| 863 | + | ||
| 864 | + | // Set the ghost label text (just the suffix) and position it horizontally | |
| 865 | + | o.ghostLabel.SetText(suffix) | |
| 866 | + | ||
| 867 | + | marginStart := widthPx | |
| 868 | + | delegate := o.entry.GetDelegate() | |
| 869 | + | if delegate != nil && o.entryOverlay != nil { | |
| 870 | + | textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer()) | |
| 871 | + | if textWidget != nil { | |
| 872 | + | var textBounds graphene.Rect | |
| 873 | + | if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) { | |
| 874 | + | cursorPos := uint(utf8.RuneCountInString(originalInput)) | |
| 875 | + | var strongRect, weakRect graphene.Rect | |
| 876 | + | textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect) | |
| 877 | + | cursorX := float64(textBounds.GetX() + strongRect.GetX()) | |
| 878 | + | marginStart = int(math.Round(cursorX)) | |
| 879 | + | } | |
| 880 | + | } | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | // Clamp horizontal position to entry bounds | |
| 884 | + | if marginStart < 0 { | |
| 885 | + | marginStart = 0 | |
| 886 | + | } | |
| 887 | + | if o.entryOverlay != nil { | |
| 888 | + | var entryBounds graphene.Rect | |
| 889 | + | if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) { | |
| 890 | + | minStart := int(math.Round(float64(entryBounds.GetX()))) | |
| 891 | + | if marginStart < minStart { | |
| 892 | + | marginStart = minStart | |
| 893 | + | } | |
| 894 | + | } | |
| 895 | + | } | |
| 896 | + | ||
| 897 | + | o.ghostLabel.SetMarginStart(marginStart) | |
| 898 | + | // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI | |
| 899 | + | o.ghostLabel.SetVisible(true) | |
| 900 | + | ||
| 901 | + | // Hide placeholder text so it doesn't show through ghost text | |
| 902 | + | emptyPlaceholder := "" | |
| 903 | + | o.entry.SetPlaceholderText(&emptyPlaceholder) | |
| 904 | + | ||
| 905 | + | return false | |
| 906 | + | } | |
| 907 | + | glib.IdleAdd(&cb, 0) | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | // clearGhostText hides the ghost completion text. | |
| 911 | + | func (o *Omnibox) clearGhostText() { | |
| 912 | + | o.mu.Lock() | |
| 913 | + | o.ghostSuffix = "" | |
| 914 | + | o.ghostFullText = "" | |
| 915 | + | o.hasGhostText = false | |
| 916 | + | o.mu.Unlock() | |
| 917 | + | ||
| 918 | + | if o.ghostLabel == nil { | |
| 919 | + | return | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 923 | + | if o.ghostLabel != nil { | |
| 924 | + | o.ghostLabel.SetVisible(false) | |
| 925 | + | o.ghostLabel.SetText("") | |
| 926 | + | } | |
| 927 | + | // Restore placeholder text | |
| 928 | + | if o.entry != nil { | |
| 929 | + | placeholder := "Search history or enter URL… (! lists bangs)" | |
| 930 | + | o.entry.SetPlaceholderText(&placeholder) | |
| 931 | + | } | |
| 932 | + | return false | |
| 933 | + | } | |
| 934 | + | glib.IdleAdd(&cb, 0) | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | // acceptGhostCompletion accepts the ghost text and fills the input. | |
| 938 | + | func (o *Omnibox) acceptGhostCompletion() { | |
| 939 | + | o.mu.Lock() | |
| 940 | + | if !o.hasGhostText || o.ghostFullText == "" { | |
| 941 | + | o.mu.Unlock() | |
| 942 | + | return | |
| 943 | + | } | |
| 944 | + | fullText := o.ghostFullText | |
| 945 | + | o.isAcceptingGhost = true | |
| 946 | + | o.realInput = fullText | |
| 947 | + | o.ghostSuffix = "" | |
| 948 | + | o.ghostFullText = "" | |
| 949 | + | o.hasGhostText = false | |
| 950 | + | o.mu.Unlock() | |
| 951 | + | ||
| 952 | + | // Update entry text and clear ghost label | |
| 953 | + | o.entry.SetText(fullText) | |
| 954 | + | o.entry.SetPosition(-1) // Move cursor to end | |
| 955 | + | ||
| 956 | + | if o.ghostLabel != nil { | |
| 957 | + | o.ghostLabel.SetVisible(false) | |
| 958 | + | } | |
| 959 | + | ||
| 960 | + | o.mu.Lock() | |
| 961 | + | o.isAcceptingGhost = false | |
| 962 | + | o.mu.Unlock() | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | // updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion. | |
| 966 | + | // The query parameter is the search query that was used, ensuring we don't use stale input. | |
| 967 | + | func (o *Omnibox) updateGhostFromSuggestion(query string) { | |
| 968 | + | if o.autocompleteUC == nil { | |
| 969 | + | return | |
| 970 | + | } | |
| 971 | + | ||
| 972 | + | o.mu.RLock() | |
| 973 | + | bangMode := o.bangMode | |
| 974 | + | o.mu.RUnlock() | |
| 975 | + | ||
| 976 | + | // Use the query that triggered this search, not realInput which may have changed | |
| 977 | + | userInput := query | |
| 978 | + | ||
| 979 | + | // Don't show ghost text in bang mode (it has its own completion) | |
| 980 | + | if bangMode || userInput == "" { | |
| 981 | + | o.clearGhostText() | |
| 982 | + | return | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | go func() { | |
| 986 | + | output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput}) | |
| 987 | + | if !output.Found || output.Suggestion == nil { | |
| 988 | + | o.clearGhostText() | |
| 989 | + | return | |
| 990 | + | } | |
| 991 | + | // Pass the original input so setGhostText can verify it's still current | |
| 992 | + | o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText) | |
| 993 | + | }() | |
| 994 | + | } | |
| 995 | + | ||
| 996 | + | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | |
| 997 | + | // When a row is selected via arrow keys, show the URL as ghost text even if | |
| 998 | + | // the user's input isn't a prefix - this shows what Tab would fill in. | |
| 999 | + | func (o *Omnibox) updateGhostFromURL(targetURL string) { | |
| 1000 | + | if targetURL == "" { | |
| 1001 | + | o.clearGhostText() | |
| 1002 | + | return | |
| 1003 | + | } | |
| 1004 | + | ||
| 1005 | + | o.mu.RLock() | |
| 1006 | + | userInput := o.realInput | |
| 1007 | + | o.mu.RUnlock() | |
| 1008 | + | ||
| 1009 | + | // Strip protocol from URL for cleaner display | |
| 1010 | + | displayURL := autocomplete.StripProtocol(targetURL) | |
| 1011 | + | ||
| 1012 | + | // Try to compute proper suffix if input is a prefix of the URL | |
| 1013 | + | if o.autocompleteUC != nil && userInput != "" { | |
| 1014 | + | output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL) | |
| 1015 | + | if output.Found && output.Suggestion != nil { | |
| 1016 | + | o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText) | |
| 1017 | + | return | |
| 1018 | + | } | |
| 1019 | + | } | |
| 1020 | + | ||
| 1021 | + | // If user has typed something but it's not a prefix match, don't show ghost text | |
| 1022 | + | // This prevents confusing ghost text when scrolling through history | |
| 1023 | + | if userInput != "" { | |
| 1024 | + | o.clearGhostText() | |
| 1025 | + | return | |
| 1026 | + | } | |
| 1027 | + | ||
| 1028 | + | // Only show full URL as ghost text when input is empty (initial state) | |
| 1029 | + | o.setGhostText("", displayURL, displayURL) | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | // updateGhostFromSelection updates ghost text based on the currently selected row. | |
| 1033 | + | func (o *Omnibox) updateGhostFromSelection() { | |
| 1034 | + | o.mu.RLock() | |
| 1035 | + | idx := o.selectedIndex | |
| 1036 | + | mode := o.viewMode | |
| 1037 | + | bangMode := o.bangMode | |
| 1038 | + | suggestions := o.suggestions | |
| 1039 | + | favorites := o.favorites | |
| 1040 | + | o.mu.RUnlock() | |
| 1041 | + | ||
| 1042 | + | // No ghost text in bang mode | |
| 1043 | + | if bangMode { | |
| 1044 | + | return | |
| 1045 | + | } | |
| 1046 | + | ||
| 1047 | + | var targetURL string | |
| 1048 | + | if mode == ViewModeHistory { | |
| 1049 | + | if idx >= 0 && idx < len(suggestions) { | |
| 1050 | + | targetURL = suggestions[idx].URL | |
| 1051 | + | } | |
| 1052 | + | } else { | |
| 1053 | + | if idx >= 0 && idx < len(favorites) { | |
| 1054 | + | targetURL = favorites[idx].URL | |
| 1055 | + | } | |
| 1056 | + | } | |
| 1057 | + | ||
| 1058 | + | if targetURL == "" { | |
| 1059 | + | o.clearGhostText() | |
| 1060 | + | return | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | o.updateGhostFromURL(targetURL) | |
| 1064 | + | } | |
| 1065 | + | ||
| 617 | 1066 | // performSearch executes the search based on current view mode and query. | |
| 618 | 1067 | func (o *Omnibox) performSearch() { | |
| 619 | 1068 | o.mu.RLock() | |
| @@ -707,8 +1156,8 @@ func (o *Omnibox) performSearch() { | |||
| 707 | 1156 | }) | |
| 708 | 1157 | } | |
| 709 | 1158 | ||
| 710 | - | // Marshal back to GTK main thread | |
| 711 | - | o.idleAddUpdateSuggestions(suggestions) | |
| 1159 | + | // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input | |
| 1160 | + | o.idleAddUpdateSuggestions(suggestions, query) | |
| 712 | 1161 | }() | |
| 713 | 1162 | } | |
| 714 | 1163 | ||
| @@ -725,7 +1174,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 725 | 1174 | ||
| 726 | 1175 | switch o.initialBehavior { | |
| 727 | 1176 | case "none": | |
| 728 | - | o.idleAddUpdateSuggestions(nil) | |
| 1177 | + | o.idleAddUpdateSuggestions(nil, "") | |
| 729 | 1178 | return | |
| 730 | 1179 | ||
| 731 | 1180 | case "most_visited", "recent", "": | |
| @@ -770,7 +1219,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 770 | 1219 | } | |
| 771 | 1220 | } | |
| 772 | 1221 | ||
| 773 | - | o.idleAddUpdateSuggestions(suggestions) | |
| 1222 | + | o.idleAddUpdateSuggestions(suggestions, "") | |
| 774 | 1223 | }() | |
| 775 | 1224 | } | |
| 776 | 1225 | ||
| @@ -808,7 +1257,7 @@ func (o *Omnibox) loadFavorites(query string) { | |||
| 808 | 1257 | }) | |
| 809 | 1258 | } | |
| 810 | 1259 | ||
| 811 | - | o.idleAddUpdateFavorites(favorites) | |
| 1260 | + | o.idleAddUpdateFavorites(favorites, query) | |
| 812 | 1261 | }() | |
| 813 | 1262 | } | |
| 814 | 1263 | ||
| @@ -910,7 +1359,8 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||
| 910 | 1359 | } | |
| 911 | 1360 | ||
| 912 | 1361 | // updateSuggestions updates the list with history suggestions. | |
| 913 | - | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |
| 1362 | + | // The query parameter is the search query that triggered this update. | |
| 1363 | + | func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) { | |
| 914 | 1364 | o.mu.Lock() | |
| 915 | 1365 | o.bangMode = false | |
| 916 | 1366 | o.bangSuggestions = nil | |
| @@ -927,14 +1377,19 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||
| 927 | 1377 | } | |
| 928 | 1378 | o.resizeAndCenter(rowCount) | |
| 929 | 1379 | ||
| 930 | - | // Select first item if available | |
| 1380 | + | // Select first item if available and update ghost text | |
| 931 | 1381 | if rowCount > 0 { | |
| 932 | 1382 | o.selectIndex(0) | |
| 1383 | + | // Update ghost text from first suggestion | |
| 1384 | + | o.updateGhostFromSuggestion(query) | |
| 1385 | + | } else { | |
| 1386 | + | o.clearGhostText() | |
| 933 | 1387 | } | |
| 934 | 1388 | } | |
| 935 | 1389 | ||
| 936 | 1390 | // updateFavorites updates the list with favorites. | |
| 937 | - | func (o *Omnibox) updateFavorites(favorites []Favorite) { | |
| 1391 | + | // The query parameter is the search query that triggered this update. | |
| 1392 | + | func (o *Omnibox) updateFavorites(favorites []Favorite, query string) { | |
| 938 | 1393 | o.mu.Lock() | |
| 939 | 1394 | o.bangMode = false | |
| 940 | 1395 | o.bangSuggestions = nil | |
| @@ -951,9 +1406,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) { | |||
| 951 | 1406 | } | |
| 952 | 1407 | o.resizeAndCenter(rowCount) | |
| 953 | 1408 | ||
| 954 | - | // Select first item if available | |
| 1409 | + | // Select first item if available and update ghost text | |
| 955 | 1410 | if rowCount > 0 { | |
| 956 | 1411 | o.selectIndex(0) | |
| 1412 | + | // Update ghost text from first favorite | |
| 1413 | + | o.updateGhostFromSuggestion(query) | |
| 1414 | + | } else { | |
| 1415 | + | o.clearGhostText() | |
| 957 | 1416 | } | |
| 958 | 1417 | } | |
| 959 | 1418 | ||
| @@ -1272,21 +1731,21 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1272 | 1731 | ||
| 1273 | 1732 | var targetURL string | |
| 1274 | 1733 | ||
| 1275 | - | // Check if user typed a URL-like string (contains . and no spaces) | |
| 1276 | - | // If so, navigate to that URL directly instead of the selection | |
| 1277 | - | if o.looksLikeURL(entryText) { | |
| 1278 | - | targetURL = o.buildURL(entryText) | |
| 1279 | - | } else if idx < 0 { | |
| 1280 | - | // No selection - use entry text as URL/search | |
| 1281 | - | targetURL = o.buildURL(entryText) | |
| 1282 | - | } else if mode == ViewModeHistory { | |
| 1283 | - | if idx < len(suggestions) { | |
| 1284 | - | targetURL = suggestions[idx].URL | |
| 1734 | + | // If user has selected a result, navigate to that result | |
| 1735 | + | // Otherwise, treat entry text as URL/search | |
| 1736 | + | if idx >= 0 { | |
| 1737 | + | if mode == ViewModeHistory { | |
| 1738 | + | if idx < len(suggestions) { | |
| 1739 | + | targetURL = suggestions[idx].URL | |
| 1740 | + | } | |
| 1741 | + | } else { | |
| 1742 | + | if idx < len(favorites) { | |
| 1743 | + | targetURL = favorites[idx].URL | |
| 1744 | + | } | |
| 1285 | 1745 | } | |
| 1286 | 1746 | } else { | |
| 1287 | - | if idx < len(favorites) { | |
| 1288 | - | targetURL = favorites[idx].URL | |
| 1289 | - | } | |
| 1747 | + | // No selection - use entry text as URL/search | |
| 1748 | + | targetURL = o.buildURL(entryText) | |
| 1290 | 1749 | } | |
| 1291 | 1750 | ||
| 1292 | 1751 | if targetURL == "" { | |
| @@ -1300,12 +1759,6 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1300 | 1759 | } | |
| 1301 | 1760 | } | |
| 1302 | 1761 | ||
| 1303 | - | // looksLikeURL checks if the text appears to be a URL (not a search query). | |
| 1304 | - | // Returns true for strings like "github.com", "google.com/search", etc. | |
| 1305 | - | func (o *Omnibox) looksLikeURL(text string) bool { | |
| 1306 | - | return url.LooksLikeURL(text) | |
| 1307 | - | } | |
| 1308 | - | ||
| 1309 | 1762 | // toggleFavorite adds or removes the selected item from favorites. | |
| 1310 | 1763 | // In History mode: adds the selected item to favorites | |
| 1311 | 1764 | // In Favorites mode: removes the selected item from favorites | |
| @@ -1542,8 +1995,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 1542 | 1995 | return | |
| 1543 | 1996 | } | |
| 1544 | 1997 | o.visible = true | |
| 1998 | + | // Initialize ghost text state | |
| 1999 | + | o.realInput = query | |
| 2000 | + | o.ghostSuffix = "" | |
| 2001 | + | o.ghostFullText = "" | |
| 2002 | + | o.hasGhostText = false | |
| 2003 | + | o.isAcceptingGhost = false | |
| 1545 | 2004 | o.mu.Unlock() | |
| 1546 | 2005 | ||
| 2006 | + | // Clear any stale ghost text display | |
| 2007 | + | if o.ghostLabel != nil { | |
| 2008 | + | o.ghostLabel.SetVisible(false) | |
| 2009 | + | } | |
| 2010 | + | ||
| 1547 | 2011 | // Set initial query | |
| 1548 | 2012 | o.entry.SetText(query) | |
| 1549 | 2013 | ||
| @@ -1604,9 +2068,15 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 1604 | 2068 | ||
| 1605 | 2069 | // Clear state | |
| 1606 | 2070 | o.clearBangState() | |
| 2071 | + | o.clearGhostText() | |
| 1607 | 2072 | o.entry.SetText("") | |
| 1608 | 2073 | o.listBox.RemoveAll() | |
| 1609 | 2074 | ||
| 2075 | + | // Reset realInput | |
| 2076 | + | o.mu.Lock() | |
| 2077 | + | o.realInput = "" | |
| 2078 | + | o.mu.Unlock() | |
| 2079 | + | ||
| 1610 | 2080 | if o.onClose != nil { | |
| 1611 | 2081 | o.onClose() | |
| 1612 | 2082 | } | |
| @@ -1662,9 +2132,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |||
| 1662 | 2132 | } | |
| 1663 | 2133 | ||
| 1664 | 2134 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | |
| 1665 | - | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) { | |
| 2135 | + | // The query parameter is passed through to ensure ghost text uses the correct input. | |
| 2136 | + | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) { | |
| 1666 | 2137 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 1667 | - | o.updateSuggestions(suggestions) | |
| 2138 | + | o.updateSuggestions(suggestions, query) | |
| 1668 | 2139 | return false // One-shot callback | |
| 1669 | 2140 | } | |
| 1670 | 2141 | glib.IdleAdd(&cb, 0) | |
| @@ -1686,9 +2157,10 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} { | |||
| 1686 | 2157 | } | |
| 1687 | 2158 | ||
| 1688 | 2159 | // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread. | |
| 1689 | - | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) { | |
| 2160 | + | // The query parameter is passed through to ensure ghost text uses the correct input. | |
| 2161 | + | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) { | |
| 1690 | 2162 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 1691 | - | o.updateFavorites(favorites) | |
| 2163 | + | o.updateFavorites(favorites, query) | |
| 1692 | 2164 | return false // One-shot callback, return false to remove source | |
| 1693 | 2165 | } | |
| 1694 | 2166 | glib.IdleAdd(&cb, 0) | |
| @@ -1716,6 +2188,8 @@ func (o *Omnibox) Destroy() { | |||
| 1716 | 2188 | ||
| 1717 | 2189 | o.parentOverlay = nil | |
| 1718 | 2190 | o.retainedCallbacks = nil | |
| 2191 | + | o.clickOutsideController = nil | |
| 2192 | + | o.clickOutsideCb = nil | |
| 1719 | 2193 | ||
| 1720 | 2194 | if o.outerBox != nil { | |
| 1721 | 2195 | o.outerBox.Unparent() | |
M internal/ui/coordinator/content.go +23 -0
| @@ -24,6 +24,12 @@ import ( | |||
| 24 | 24 | const ( | |
| 25 | 25 | aboutBlankURI = "about:blank" | |
| 26 | 26 | logURLMaxLen = 80 | |
| 27 | + | ||
| 28 | + | // Dark theme background color (#0a0a0b) as float32 RGBA values | |
| 29 | + | darkBgR = 0.039 | |
| 30 | + | darkBgG = 0.039 | |
| 31 | + | darkBgB = 0.043 | |
| 32 | + | darkBgA = 1.0 | |
| 27 | 33 | ) | |
| 28 | 34 | ||
| 29 | 35 | // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. | |
| @@ -936,6 +942,23 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 936 | 942 | return | |
| 937 | 943 | } | |
| 938 | 944 | ||
| 945 | + | // Set appropriate background color based on page type to prevent dark background bleeding. | |
| 946 | + | switch { | |
| 947 | + | case strings.HasPrefix(url, "dumb://"): | |
| 948 | + | // Internal pages: apply themed background | |
| 949 | + | theme, ok := c.getCurrentTheme() | |
| 950 | + | if ok && theme.prefersDark { | |
| 951 | + | wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA) | |
| 952 | + | } else { | |
| 953 | + | wv.ResetBackgroundToDefault() | |
| 954 | + | } | |
| 955 | + | case strings.HasPrefix(url, "about:"): | |
| 956 | + | // Keep pool background (no action) | |
| 957 | + | default: | |
| 958 | + | // External pages: white background | |
| 959 | + | wv.ResetBackgroundToDefault() | |
| 960 | + | } | |
| 961 | + | ||
| 939 | 962 | // Show the WebView now that content is being painted | |
| 940 | 963 | // (WebViews are hidden on creation to avoid white flash) | |
| 941 | 964 | // Skip showing if this is about:blank but the pane is loading a different URL | |
M internal/ui/input/global_shortcuts.go +20 -11
| @@ -94,20 +94,29 @@ func NewGlobalShortcutHandler( | |||
| 94 | 94 | Msg("registered global shortcut") | |
| 95 | 95 | ||
| 96 | 96 | if cfg != nil { | |
| 97 | - | registerFromConfig := func(key string, action Action) { | |
| 98 | - | binding, ok := ParseKeyString(key) | |
| 97 | + | // Map action names to action constants for global shortcuts | |
| 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 | 107 | if !ok { | |
| 100 | - | log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut") | |
| 101 | - | return | |
| 108 | + | continue | |
| 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 | 122 | // Attach to window | |
M internal/ui/input/keyboard.go +13 -0
| @@ -79,6 +79,19 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { | |||
| 79 | 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 | 95 | // SetOnModeChange sets the callback for mode changes (for UI updates). | |
| 83 | 96 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |
| 84 | 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 | 325 | // This follows Zellij-style modal keyboard interface. | |
| 326 | 326 | // | |
| 327 | 327 | // However, these standard browser shortcuts ARE global: | |
| 328 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ClosePane); ok { | |
| 329 | - | s.Global[binding] = ActionClosePane // Ctrl+W closes active pane | |
| 330 | - | } | |
| 331 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.NextTab); ok { | |
| 332 | - | s.Global[binding] = ActionNextTab // Ctrl+Tab | |
| 333 | - | } | |
| 334 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | |
| 335 | - | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | |
| 328 | + | // Map action names to action constants | |
| 329 | + | actionMap := map[string]Action{ | |
| 330 | + | "close_pane": ActionClosePane, | |
| 331 | + | "next_tab": ActionNextTab, | |
| 332 | + | "previous_tab": ActionPreviousTab, | |
| 333 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 334 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 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 | - | s.Global[binding] = ActionConsumeOrExpelLeft | |
| 340 | - | } | |
| 341 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok { | |
| 342 | - | s.Global[binding] = ActionConsumeOrExpelRight | |
| 343 | - | } | |
| 344 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok { | |
| 345 | - | s.Global[binding] = ActionConsumeOrExpelUp | |
| 346 | - | } | |
| 347 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok { | |
| 348 | - | s.Global[binding] = ActionConsumeOrExpelDown | |
| 339 | + | for actionName, actionBinding := range cfg.Shortcuts.Actions { | |
| 340 | + | action, ok := actionMap[actionName] | |
| 341 | + | if !ok { | |
| 342 | + | continue | |
| 343 | + | } | |
| 344 | + | for _, keyStr := range actionBinding.Keys { | |
| 345 | + | if binding, ok := ParseKeyString(keyStr); ok { | |
| 346 | + | s.Global[binding] = action | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | 349 | } | |
| 350 | 350 | } | |
| 351 | 351 | ||
M internal/ui/theme/css.go +10 -0
| @@ -409,6 +409,16 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | |||
| 409 | 409 | color: var(--accent); | |
| 410 | 410 | font-weight: 600; | |
| 411 | 411 | } | |
| 412 | + | ||
| 413 | + | /* Ghost text autocomplete overlay - positioned to align with entry text */ | |
| 414 | + | .omnibox-ghost { | |
| 415 | + | font-size: 1em; | |
| 416 | + | color: var(--muted); | |
| 417 | + | margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */ | |
| 418 | + | background-color: transparent; | |
| 419 | + | /* Allow clicks to pass through to entry below */ | |
| 420 | + | pointer-events: none; | |
| 421 | + | } | |
| 412 | 422 | ` | |
| 413 | 423 | } | |
| 414 | 424 | ||
M webui/eslint.config.js +21 -0
| @@ -1,9 +1,30 @@ | |||
| 1 | 1 | import js from "@eslint/js"; | |
| 2 | + | import svelte from "eslint-plugin-svelte"; | |
| 3 | + | import globals from "globals"; | |
| 2 | 4 | import tseslint from "typescript-eslint"; | |
| 5 | + | import svelteConfig from "./svelte.config.js"; | |
| 3 | 6 | ||
| 4 | 7 | export default tseslint.config( | |
| 5 | 8 | js.configs.recommended, | |
| 6 | 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 | 29 | files: ["**/*.{js,mjs,cjs,ts}"], | |
| 9 | 30 | languageOptions: { | |
M webui/package-lock.json +308 -7
| @@ -25,6 +25,8 @@ | |||
| 25 | 25 | "@typescript-eslint/parser": "^7.0.0", | |
| 26 | 26 | "autoprefixer": "^10.4.20", | |
| 27 | 27 | "eslint": "^8.57.0", | |
| 28 | + | "eslint-plugin-svelte": "^3.14.0", | |
| 29 | + | "globals": "^17.1.0", | |
| 28 | 30 | "postcss": "^8.4.47", | |
| 29 | 31 | "svelte": "^5.0.0", | |
| 30 | 32 | "svelte-awesome-color-picker": "^4.1.0", | |
| @@ -505,6 +507,22 @@ | |||
| 505 | 507 | "concat-map": "0.0.1" | |
| 506 | 508 | } | |
| 507 | 509 | }, | |
| 510 | + | "node_modules/@eslint/eslintrc/node_modules/globals": { | |
| 511 | + | "version": "13.24.0", | |
| 512 | + | "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", | |
| 513 | + | "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", | |
| 514 | + | "dev": true, | |
| 515 | + | "license": "MIT", | |
| 516 | + | "dependencies": { | |
| 517 | + | "type-fest": "^0.20.2" | |
| 518 | + | }, | |
| 519 | + | "engines": { | |
| 520 | + | "node": ">=8" | |
| 521 | + | }, | |
| 522 | + | "funding": { | |
| 523 | + | "url": "https://github.com/sponsors/sindresorhus" | |
| 524 | + | } | |
| 525 | + | }, | |
| 508 | 526 | "node_modules/@eslint/eslintrc/node_modules/minimatch": { | |
| 509 | 527 | "version": "3.1.2", | |
| 510 | 528 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", | |
| @@ -2356,6 +2374,19 @@ | |||
| 2356 | 2374 | "node": ">= 8" | |
| 2357 | 2375 | } | |
| 2358 | 2376 | }, | |
| 2377 | + | "node_modules/cssesc": { | |
| 2378 | + | "version": "3.0.0", | |
| 2379 | + | "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", | |
| 2380 | + | "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", | |
| 2381 | + | "dev": true, | |
| 2382 | + | "license": "MIT", | |
| 2383 | + | "bin": { | |
| 2384 | + | "cssesc": "bin/cssesc" | |
| 2385 | + | }, | |
| 2386 | + | "engines": { | |
| 2387 | + | "node": ">=4" | |
| 2388 | + | } | |
| 2389 | + | }, | |
| 2359 | 2390 | "node_modules/debug": { | |
| 2360 | 2391 | "version": "4.4.3", | |
| 2361 | 2392 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | |
| @@ -2583,6 +2614,53 @@ | |||
| 2583 | 2614 | "url": "https://opencollective.com/eslint" | |
| 2584 | 2615 | } | |
| 2585 | 2616 | }, | |
| 2617 | + | "node_modules/eslint-plugin-svelte": { | |
| 2618 | + | "version": "3.14.0", | |
| 2619 | + | "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.14.0.tgz", | |
| 2620 | + | "integrity": "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==", | |
| 2621 | + | "dev": true, | |
| 2622 | + | "license": "MIT", | |
| 2623 | + | "dependencies": { | |
| 2624 | + | "@eslint-community/eslint-utils": "^4.6.1", | |
| 2625 | + | "@jridgewell/sourcemap-codec": "^1.5.0", | |
| 2626 | + | "esutils": "^2.0.3", | |
| 2627 | + | "globals": "^16.0.0", | |
| 2628 | + | "known-css-properties": "^0.37.0", | |
| 2629 | + | "postcss": "^8.4.49", | |
| 2630 | + | "postcss-load-config": "^3.1.4", | |
| 2631 | + | "postcss-safe-parser": "^7.0.0", | |
| 2632 | + | "semver": "^7.6.3", | |
| 2633 | + | "svelte-eslint-parser": "^1.4.0" | |
| 2634 | + | }, | |
| 2635 | + | "engines": { | |
| 2636 | + | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | |
| 2637 | + | }, | |
| 2638 | + | "funding": { | |
| 2639 | + | "url": "https://github.com/sponsors/ota-meshi" | |
| 2640 | + | }, | |
| 2641 | + | "peerDependencies": { | |
| 2642 | + | "eslint": "^8.57.1 || ^9.0.0", | |
| 2643 | + | "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" | |
| 2644 | + | }, | |
| 2645 | + | "peerDependenciesMeta": { | |
| 2646 | + | "svelte": { | |
| 2647 | + | "optional": true | |
| 2648 | + | } | |
| 2649 | + | } | |
| 2650 | + | }, | |
| 2651 | + | "node_modules/eslint-plugin-svelte/node_modules/globals": { | |
| 2652 | + | "version": "16.5.0", | |
| 2653 | + | "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", | |
| 2654 | + | "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", | |
| 2655 | + | "dev": true, | |
| 2656 | + | "license": "MIT", | |
| 2657 | + | "engines": { | |
| 2658 | + | "node": ">=18" | |
| 2659 | + | }, | |
| 2660 | + | "funding": { | |
| 2661 | + | "url": "https://github.com/sponsors/sindresorhus" | |
| 2662 | + | } | |
| 2663 | + | }, | |
| 2586 | 2664 | "node_modules/eslint-scope": { | |
| 2587 | 2665 | "version": "7.2.2", | |
| 2588 | 2666 | "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", | |
| @@ -2624,6 +2702,22 @@ | |||
| 2624 | 2702 | "concat-map": "0.0.1" | |
| 2625 | 2703 | } | |
| 2626 | 2704 | }, | |
| 2705 | + | "node_modules/eslint/node_modules/globals": { | |
| 2706 | + | "version": "13.24.0", | |
| 2707 | + | "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", | |
| 2708 | + | "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", | |
| 2709 | + | "dev": true, | |
| 2710 | + | "license": "MIT", | |
| 2711 | + | "dependencies": { | |
| 2712 | + | "type-fest": "^0.20.2" | |
| 2713 | + | }, | |
| 2714 | + | "engines": { | |
| 2715 | + | "node": ">=8" | |
| 2716 | + | }, | |
| 2717 | + | "funding": { | |
| 2718 | + | "url": "https://github.com/sponsors/sindresorhus" | |
| 2719 | + | } | |
| 2720 | + | }, | |
| 2627 | 2721 | "node_modules/eslint/node_modules/minimatch": { | |
| 2628 | 2722 | "version": "3.1.2", | |
| 2629 | 2723 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", | |
| @@ -2962,16 +3056,13 @@ | |||
| 2962 | 3056 | } | |
| 2963 | 3057 | }, | |
| 2964 | 3058 | "node_modules/globals": { | |
| 2965 | - | "version": "13.24.0", | |
| 2966 | - | "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", | |
| 2967 | - | "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", | |
| 3059 | + | "version": "17.1.0", | |
| 3060 | + | "resolved": "https://registry.npmjs.org/globals/-/globals-17.1.0.tgz", | |
| 3061 | + | "integrity": "sha512-8HoIcWI5fCvG5NADj4bDav+er9B9JMj2vyL2pI8D0eismKyUvPLTSs+Ln3wqhwcp306i73iyVnEKx3F6T47TGw==", | |
| 2968 | 3062 | "dev": true, | |
| 2969 | 3063 | "license": "MIT", | |
| 2970 | - | "dependencies": { | |
| 2971 | - | "type-fest": "^0.20.2" | |
| 2972 | - | }, | |
| 2973 | 3064 | "engines": { | |
| 2974 | - | "node": ">=8" | |
| 3065 | + | "node": ">=18" | |
| 2975 | 3066 | }, | |
| 2976 | 3067 | "funding": { | |
| 2977 | 3068 | "url": "https://github.com/sponsors/sindresorhus" | |
| @@ -3207,6 +3298,13 @@ | |||
| 3207 | 3298 | "node": ">=6" | |
| 3208 | 3299 | } | |
| 3209 | 3300 | }, | |
| 3301 | + | "node_modules/known-css-properties": { | |
| 3302 | + | "version": "0.37.0", | |
| 3303 | + | "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", | |
| 3304 | + | "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", | |
| 3305 | + | "dev": true, | |
| 3306 | + | "license": "MIT" | |
| 3307 | + | }, | |
| 3210 | 3308 | "node_modules/levn": { | |
| 3211 | 3309 | "version": "0.4.1", | |
| 3212 | 3310 | "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | |
| @@ -3482,6 +3580,16 @@ | |||
| 3482 | 3580 | "url": "https://opencollective.com/parcel" | |
| 3483 | 3581 | } | |
| 3484 | 3582 | }, | |
| 3583 | + | "node_modules/lilconfig": { | |
| 3584 | + | "version": "2.1.0", | |
| 3585 | + | "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", | |
| 3586 | + | "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", | |
| 3587 | + | "dev": true, | |
| 3588 | + | "license": "MIT", | |
| 3589 | + | "engines": { | |
| 3590 | + | "node": ">=10" | |
| 3591 | + | } | |
| 3592 | + | }, | |
| 3485 | 3593 | "node_modules/locate-character": { | |
| 3486 | 3594 | "version": "3.0.0", | |
| 3487 | 3595 | "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", | |
| @@ -3865,6 +3973,104 @@ | |||
| 3865 | 3973 | "node": "^10 || ^12 || >=14" | |
| 3866 | 3974 | } | |
| 3867 | 3975 | }, | |
| 3976 | + | "node_modules/postcss-load-config": { | |
| 3977 | + | "version": "3.1.4", | |
| 3978 | + | "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", | |
| 3979 | + | "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", | |
| 3980 | + | "dev": true, | |
| 3981 | + | "license": "MIT", | |
| 3982 | + | "dependencies": { | |
| 3983 | + | "lilconfig": "^2.0.5", | |
| 3984 | + | "yaml": "^1.10.2" | |
| 3985 | + | }, | |
| 3986 | + | "engines": { | |
| 3987 | + | "node": ">= 10" | |
| 3988 | + | }, | |
| 3989 | + | "funding": { | |
| 3990 | + | "type": "opencollective", | |
| 3991 | + | "url": "https://opencollective.com/postcss/" | |
| 3992 | + | }, | |
| 3993 | + | "peerDependencies": { | |
| 3994 | + | "postcss": ">=8.0.9", | |
| 3995 | + | "ts-node": ">=9.0.0" | |
| 3996 | + | }, | |
| 3997 | + | "peerDependenciesMeta": { | |
| 3998 | + | "postcss": { | |
| 3999 | + | "optional": true | |
| 4000 | + | }, | |
| 4001 | + | "ts-node": { | |
| 4002 | + | "optional": true | |
| 4003 | + | } | |
| 4004 | + | } | |
| 4005 | + | }, | |
| 4006 | + | "node_modules/postcss-safe-parser": { | |
| 4007 | + | "version": "7.0.1", | |
| 4008 | + | "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", | |
| 4009 | + | "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", | |
| 4010 | + | "dev": true, | |
| 4011 | + | "funding": [ | |
| 4012 | + | { | |
| 4013 | + | "type": "opencollective", | |
| 4014 | + | "url": "https://opencollective.com/postcss/" | |
| 4015 | + | }, | |
| 4016 | + | { | |
| 4017 | + | "type": "tidelift", | |
| 4018 | + | "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" | |
| 4019 | + | }, | |
| 4020 | + | { | |
| 4021 | + | "type": "github", | |
| 4022 | + | "url": "https://github.com/sponsors/ai" | |
| 4023 | + | } | |
| 4024 | + | ], | |
| 4025 | + | "license": "MIT", | |
| 4026 | + | "engines": { | |
| 4027 | + | "node": ">=18.0" | |
| 4028 | + | }, | |
| 4029 | + | "peerDependencies": { | |
| 4030 | + | "postcss": "^8.4.31" | |
| 4031 | + | } | |
| 4032 | + | }, | |
| 4033 | + | "node_modules/postcss-scss": { | |
| 4034 | + | "version": "4.0.9", | |
| 4035 | + | "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", | |
| 4036 | + | "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", | |
| 4037 | + | "dev": true, | |
| 4038 | + | "funding": [ | |
| 4039 | + | { | |
| 4040 | + | "type": "opencollective", | |
| 4041 | + | "url": "https://opencollective.com/postcss/" | |
| 4042 | + | }, | |
| 4043 | + | { | |
| 4044 | + | "type": "tidelift", | |
| 4045 | + | "url": "https://tidelift.com/funding/github/npm/postcss-scss" | |
| 4046 | + | }, | |
| 4047 | + | { | |
| 4048 | + | "type": "github", | |
| 4049 | + | "url": "https://github.com/sponsors/ai" | |
| 4050 | + | } | |
| 4051 | + | ], | |
| 4052 | + | "license": "MIT", | |
| 4053 | + | "engines": { | |
| 4054 | + | "node": ">=12.0" | |
| 4055 | + | }, | |
| 4056 | + | "peerDependencies": { | |
| 4057 | + | "postcss": "^8.4.29" | |
| 4058 | + | } | |
| 4059 | + | }, | |
| 4060 | + | "node_modules/postcss-selector-parser": { | |
| 4061 | + | "version": "7.1.1", | |
| 4062 | + | "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", | |
| 4063 | + | "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", | |
| 4064 | + | "dev": true, | |
| 4065 | + | "license": "MIT", | |
| 4066 | + | "dependencies": { | |
| 4067 | + | "cssesc": "^3.0.0", | |
| 4068 | + | "util-deprecate": "^1.0.2" | |
| 4069 | + | }, | |
| 4070 | + | "engines": { | |
| 4071 | + | "node": ">=4" | |
| 4072 | + | } | |
| 4073 | + | }, | |
| 3868 | 4074 | "node_modules/postcss-value-parser": { | |
| 3869 | 4075 | "version": "4.2.0", | |
| 3870 | 4076 | "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", | |
| @@ -4253,6 +4459,84 @@ | |||
| 4253 | 4459 | "typescript": ">=5.0.0" | |
| 4254 | 4460 | } | |
| 4255 | 4461 | }, | |
| 4462 | + | "node_modules/svelte-eslint-parser": { | |
| 4463 | + | "version": "1.4.1", | |
| 4464 | + | "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", | |
| 4465 | + | "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", | |
| 4466 | + | "dev": true, | |
| 4467 | + | "license": "MIT", | |
| 4468 | + | "dependencies": { | |
| 4469 | + | "eslint-scope": "^8.2.0", | |
| 4470 | + | "eslint-visitor-keys": "^4.0.0", | |
| 4471 | + | "espree": "^10.0.0", | |
| 4472 | + | "postcss": "^8.4.49", | |
| 4473 | + | "postcss-scss": "^4.0.9", | |
| 4474 | + | "postcss-selector-parser": "^7.0.0" | |
| 4475 | + | }, | |
| 4476 | + | "engines": { | |
| 4477 | + | "node": "^18.18.0 || ^20.9.0 || >=21.1.0", | |
| 4478 | + | "pnpm": "10.24.0" | |
| 4479 | + | }, | |
| 4480 | + | "funding": { | |
| 4481 | + | "url": "https://github.com/sponsors/ota-meshi" | |
| 4482 | + | }, | |
| 4483 | + | "peerDependencies": { | |
| 4484 | + | "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" | |
| 4485 | + | }, | |
| 4486 | + | "peerDependenciesMeta": { | |
| 4487 | + | "svelte": { | |
| 4488 | + | "optional": true | |
| 4489 | + | } | |
| 4490 | + | } | |
| 4491 | + | }, | |
| 4492 | + | "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { | |
| 4493 | + | "version": "8.4.0", | |
| 4494 | + | "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", | |
| 4495 | + | "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", | |
| 4496 | + | "dev": true, | |
| 4497 | + | "license": "BSD-2-Clause", | |
| 4498 | + | "dependencies": { | |
| 4499 | + | "esrecurse": "^4.3.0", | |
| 4500 | + | "estraverse": "^5.2.0" | |
| 4501 | + | }, | |
| 4502 | + | "engines": { | |
| 4503 | + | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | |
| 4504 | + | }, | |
| 4505 | + | "funding": { | |
| 4506 | + | "url": "https://opencollective.com/eslint" | |
| 4507 | + | } | |
| 4508 | + | }, | |
| 4509 | + | "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { | |
| 4510 | + | "version": "4.2.1", | |
| 4511 | + | "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", | |
| 4512 | + | "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", | |
| 4513 | + | "dev": true, | |
| 4514 | + | "license": "Apache-2.0", | |
| 4515 | + | "engines": { | |
| 4516 | + | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | |
| 4517 | + | }, | |
| 4518 | + | "funding": { | |
| 4519 | + | "url": "https://opencollective.com/eslint" | |
| 4520 | + | } | |
| 4521 | + | }, | |
| 4522 | + | "node_modules/svelte-eslint-parser/node_modules/espree": { | |
| 4523 | + | "version": "10.4.0", | |
| 4524 | + | "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", | |
| 4525 | + | "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", | |
| 4526 | + | "dev": true, | |
| 4527 | + | "license": "BSD-2-Clause", | |
| 4528 | + | "dependencies": { | |
| 4529 | + | "acorn": "^8.15.0", | |
| 4530 | + | "acorn-jsx": "^5.3.2", | |
| 4531 | + | "eslint-visitor-keys": "^4.2.1" | |
| 4532 | + | }, | |
| 4533 | + | "engines": { | |
| 4534 | + | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | |
| 4535 | + | }, | |
| 4536 | + | "funding": { | |
| 4537 | + | "url": "https://opencollective.com/eslint" | |
| 4538 | + | } | |
| 4539 | + | }, | |
| 4256 | 4540 | "node_modules/svelte-toolbelt": { | |
| 4257 | 4541 | "version": "0.10.6", | |
| 4258 | 4542 | "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", | |
| @@ -4729,6 +5013,13 @@ | |||
| 4729 | 5013 | "punycode": "^2.1.0" | |
| 4730 | 5014 | } | |
| 4731 | 5015 | }, | |
| 5016 | + | "node_modules/util-deprecate": { | |
| 5017 | + | "version": "1.0.2", | |
| 5018 | + | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", | |
| 5019 | + | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", | |
| 5020 | + | "dev": true, | |
| 5021 | + | "license": "MIT" | |
| 5022 | + | }, | |
| 4732 | 5023 | "node_modules/vite": { | |
| 4733 | 5024 | "version": "5.4.21", | |
| 4734 | 5025 | "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", | |
| @@ -4843,6 +5134,16 @@ | |||
| 4843 | 5134 | "dev": true, | |
| 4844 | 5135 | "license": "ISC" | |
| 4845 | 5136 | }, | |
| 5137 | + | "node_modules/yaml": { | |
| 5138 | + | "version": "1.10.2", | |
| 5139 | + | "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", | |
| 5140 | + | "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", | |
| 5141 | + | "dev": true, | |
| 5142 | + | "license": "ISC", | |
| 5143 | + | "engines": { | |
| 5144 | + | "node": ">= 6" | |
| 5145 | + | } | |
| 5146 | + | }, | |
| 4846 | 5147 | "node_modules/yocto-queue": { | |
| 4847 | 5148 | "version": "0.1.0", | |
| 4848 | 5149 | "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", | |
M webui/package.json +2 -0
| @@ -27,6 +27,8 @@ | |||
| 27 | 27 | "@typescript-eslint/parser": "^7.0.0", | |
| 28 | 28 | "autoprefixer": "^10.4.20", | |
| 29 | 29 | "eslint": "^8.57.0", | |
| 30 | + | "eslint-plugin-svelte": "^3.14.0", | |
| 31 | + | "globals": "^17.1.0", | |
| 30 | 32 | "postcss": "^8.4.47", | |
| 31 | 33 | "svelte": "^5.0.0", | |
| 32 | 34 | "svelte-awesome-color-picker": "^4.1.0", | |
A webui/src/lib/bridge.ts +163 -0
| @@ -0,0 +1,163 @@ | |||
| 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 | + | * Internal callback registry using Map for tracking pending requests. | |
| 81 | + | * Callbacks must also be exposed on window for Go backend to call, | |
| 82 | + | * but we track them here for proper cleanup and to prevent leaks. | |
| 83 | + | */ | |
| 84 | + | const pendingCallbacks = new Map<string, { | |
| 85 | + | cleanup: () => void; | |
| 86 | + | timeoutId: ReturnType<typeof setTimeout>; | |
| 87 | + | }>(); | |
| 88 | + | ||
| 89 | + | let callbackCounter = 0; | |
| 90 | + | const CALLBACK_TIMEOUT_MS = 30000; | |
| 91 | + | ||
| 92 | + | /** | |
| 93 | + | * Post a message and set up success/error callbacks with automatic cleanup. | |
| 94 | + | * Uses unique IDs to prevent naming conflicts between concurrent requests. | |
| 95 | + | * | |
| 96 | + | * @param type - Message type (e.g., "get_keybindings", "set_keybinding") | |
| 97 | + | * @param payload - Message payload (will be serialized to strip Proxies) | |
| 98 | + | * @param successCallback - Base name for success callback (will be made unique) | |
| 99 | + | * @param errorCallback - Base name for error callback (will be made unique) | |
| 100 | + | * @param onSuccess - Handler for success response | |
| 101 | + | * @param onError - Handler for error response | |
| 102 | + | */ | |
| 103 | + | export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>( | |
| 104 | + | type: string, | |
| 105 | + | payload: TPayload, | |
| 106 | + | successCallback: string, | |
| 107 | + | errorCallback: string, | |
| 108 | + | onSuccess: (response: TResponse) => void, | |
| 109 | + | onError: (error: string) => void, | |
| 110 | + | ): void { | |
| 111 | + | const id = ++callbackCounter; | |
| 112 | + | const successKey = `${successCallback}_${id}`; | |
| 113 | + | const errorKey = `${errorCallback}_${id}`; | |
| 114 | + | ||
| 115 | + | // Cleanup removes from both window and our tracking Map | |
| 116 | + | const cleanup = () => { | |
| 117 | + | const entry = pendingCallbacks.get(successKey); | |
| 118 | + | if (entry) { | |
| 119 | + | clearTimeout(entry.timeoutId); | |
| 120 | + | pendingCallbacks.delete(successKey); | |
| 121 | + | } | |
| 122 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 123 | + | delete (window as any)[successKey]; | |
| 124 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 125 | + | delete (window as any)[errorKey]; | |
| 126 | + | }; | |
| 127 | + | ||
| 128 | + | // Timeout prevents memory leaks if backend never responds | |
| 129 | + | const timeoutId = setTimeout(() => { | |
| 130 | + | cleanup(); | |
| 131 | + | onError("Request timed out"); | |
| 132 | + | }, CALLBACK_TIMEOUT_MS); | |
| 133 | + | ||
| 134 | + | // Track in our Map for proper lifecycle management | |
| 135 | + | pendingCallbacks.set(successKey, { cleanup, timeoutId }); | |
| 136 | + | ||
| 137 | + | // Expose on window for Go backend (it calls window[callbackName](response)) | |
| 138 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 139 | + | (window as any)[successKey] = (response: TResponse) => { | |
| 140 | + | cleanup(); | |
| 141 | + | onSuccess(response); | |
| 142 | + | }; | |
| 143 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 144 | + | (window as any)[errorKey] = (msg: unknown) => { | |
| 145 | + | cleanup(); | |
| 146 | + | onError(typeof msg === "string" ? msg : "Unknown error"); | |
| 147 | + | }; | |
| 148 | + | ||
| 149 | + | try { | |
| 150 | + | postMessage({ | |
| 151 | + | type, | |
| 152 | + | webviewId: getWebViewId(), | |
| 153 | + | payload: { | |
| 154 | + | ...payload, | |
| 155 | + | successCallback: successKey, | |
| 156 | + | errorCallback: errorKey, | |
| 157 | + | }, | |
| 158 | + | }); | |
| 159 | + | } catch (err) { | |
| 160 | + | cleanup(); | |
| 161 | + | onError(err instanceof Error ? err.message : String(err)); | |
| 162 | + | } | |
| 163 | + | } | |
M webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +12 -2
| @@ -2,16 +2,26 @@ | |||
| 2 | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |
| 3 | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | |
| 4 | 4 | import { cn } from "$lib/utils.js"; | |
| 5 | + | import type { Snippet } from "svelte"; | |
| 6 | + | import type { MouseEventHandler } from "svelte/elements"; | |
| 5 | 7 | ||
| 6 | 8 | let { | |
| 7 | 9 | ref = $bindable(null), | |
| 8 | 10 | class: className, | |
| 11 | + | children, | |
| 12 | + | onclick, | |
| 9 | 13 | ...restProps | |
| 10 | - | }: AlertDialogPrimitive.ActionProps = $props(); | |
| 14 | + | }: AlertDialogPrimitive.ActionProps & { | |
| 15 | + | children?: Snippet; | |
| 16 | + | onclick?: MouseEventHandler<HTMLButtonElement>; | |
| 17 | + | } = $props(); | |
| 11 | 18 | </script> | |
| 12 | 19 | ||
| 13 | 20 | <AlertDialogPrimitive.Action | |
| 14 | 21 | bind:ref | |
| 15 | 22 | class={cn(buttonVariants(), className)} | |
| 23 | + | {onclick} | |
| 16 | 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 | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |
| 3 | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | |
| 4 | 4 | import { cn } from "$lib/utils.js"; | |
| 5 | + | import type { Snippet } from "svelte"; | |
| 5 | 6 | ||
| 6 | 7 | let { | |
| 7 | 8 | ref = $bindable(null), | |
| 8 | 9 | class: className, | |
| 10 | + | children, | |
| 9 | 11 | ...restProps | |
| 10 | - | }: AlertDialogPrimitive.CancelProps = $props(); | |
| 12 | + | }: AlertDialogPrimitive.CancelProps & { | |
| 13 | + | children?: Snippet; | |
| 14 | + | } = $props(); | |
| 11 | 15 | </script> | |
| 12 | 16 | ||
| 13 | 17 | <AlertDialogPrimitive.Cancel | |
| 14 | 18 | bind:ref | |
| 15 | 19 | class={cn(buttonVariants({ variant: "outline" }), className)} | |
| 16 | 20 | {...restProps} | |
| 17 | - | /> | |
| 21 | + | > | |
| 22 | + | {@render children?.()} | |
| 23 | + | </AlertDialogPrimitive.Cancel> | |
M webui/src/lib/components/ui/color-picker/color-picker.svelte +3 -12
| @@ -21,17 +21,8 @@ | |||
| 21 | 21 | ||
| 22 | 22 | let open = $state(false); | |
| 23 | 23 | ||
| 24 | - | // svelte-awesome-color-picker uses hex binding | |
| 25 | - | let hex = $state(value); | |
| 26 | - | ||
| 27 | - | // Sync hex when value changes externally | |
| 28 | - | $effect(() => { | |
| 29 | - | hex = value; | |
| 30 | - | }); | |
| 31 | - | ||
| 32 | 24 | function handleInput(event: { hex: string | null }) { | |
| 33 | 25 | if (event.hex) { | |
| 34 | - | hex = event.hex; | |
| 35 | 26 | value = event.hex; | |
| 36 | 27 | onValueChange?.(event.hex); | |
| 37 | 28 | } | |
| @@ -60,7 +51,7 @@ | |||
| 60 | 51 | sideOffset={4} | |
| 61 | 52 | > | |
| 62 | 53 | <ColorPicker | |
| 63 | - | bind:hex | |
| 54 | + | bind:hex={value} | |
| 64 | 55 | onInput={handleInput} | |
| 65 | 56 | isDialog={false} | |
| 66 | 57 | /> | |
| @@ -68,10 +59,10 @@ | |||
| 68 | 59 | <div class="mt-2 flex items-center gap-2"> | |
| 69 | 60 | <span | |
| 70 | 61 | class="block h-6 w-6 shrink-0 border border-border" | |
| 71 | - | style="background-color: {hex};" | |
| 62 | + | style="background-color: {value};" | |
| 72 | 63 | aria-hidden="true" | |
| 73 | 64 | ></span> | |
| 74 | - | <span class="font-mono text-sm text-foreground">{hex}</span> | |
| 65 | + | <span class="font-mono text-sm text-foreground">{value}</span> | |
| 75 | 66 | </div> | |
| 76 | 67 | </Popover.Content> | |
| 77 | 68 | </Popover.Portal> | |
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 | 3 | import { ModeWatcher } from "mode-watcher"; | |
| 4 | 4 | import ConfigShell from "./config/ConfigShell.svelte"; | |
| 5 | 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | |
| 6 | + | import KeybindingsTab from "./config/KeybindingsTab.svelte"; | |
| 6 | 7 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |
| 7 | 8 | import { Button } from "$lib/components/ui/button"; | |
| 8 | 9 | import { ColorPicker } from "$lib/components/ui/color-picker"; | |
| @@ -13,6 +14,12 @@ | |||
| 13 | 14 | import * as Tabs from "$lib/components/ui/tabs"; | |
| 14 | 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 | 23 | type ColorPalette = { | |
| 17 | 24 | background: string; | |
| 18 | 25 | surface: string; | |
| @@ -118,8 +125,8 @@ | |||
| 118 | 125 | const response = await fetch("/api/config"); | |
| 119 | 126 | if (!response.ok) throw new Error("Failed to fetch config"); | |
| 120 | 127 | config = (await response.json()) as ConfigDTO; | |
| 121 | - | } catch (e: any) { | |
| 122 | - | loadError = e.message; | |
| 128 | + | } catch (e: unknown) { | |
| 129 | + | loadError = getErrorMessage(e); | |
| 123 | 130 | console.error("[config] load failed", e); | |
| 124 | 131 | } finally { | |
| 125 | 132 | loading = false; | |
| @@ -139,14 +146,14 @@ | |||
| 139 | 146 | const response = await fetch("/api/config/default"); | |
| 140 | 147 | if (!response.ok) throw new Error("Failed to fetch default config"); | |
| 141 | 148 | config = (await response.json()) as ConfigDTO; | |
| 142 | - | } catch (e: any) { | |
| 149 | + | } catch (e: unknown) { | |
| 143 | 150 | console.error("[config] reset defaults failed", e); | |
| 144 | 151 | } | |
| 145 | 152 | resetDialogOpen = false; | |
| 146 | 153 | } | |
| 147 | 154 | ||
| 148 | 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 | 157 | if (bridge && typeof bridge.postMessage === "function") { | |
| 151 | 158 | return bridge; | |
| 152 | 159 | } | |
| @@ -154,7 +161,7 @@ | |||
| 154 | 161 | } | |
| 155 | 162 | ||
| 156 | 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 | 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 | 205 | clearSaveTimeout(); | |
| 199 | 206 | console.debug("[config] save success", resp); | |
| 200 | 207 | saveSuccess = true; | |
| @@ -212,7 +219,7 @@ | |||
| 212 | 219 | saveSuccess = false; | |
| 213 | 220 | }, 3000); | |
| 214 | 221 | }; | |
| 215 | - | (window as any).__dumber_config_error = (msg: unknown) => { | |
| 222 | + | window.__dumber_config_error = (msg: unknown) => { | |
| 216 | 223 | clearSaveTimeout(); | |
| 217 | 224 | console.error("[config] save error", msg); | |
| 218 | 225 | saveError = typeof msg === "string" ? msg : "Failed to save config"; | |
| @@ -227,14 +234,13 @@ | |||
| 227 | 234 | webview_id: webviewId, | |
| 228 | 235 | payload, | |
| 229 | 236 | }); | |
| 230 | - | console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length }); | |
| 231 | 237 | } catch (postErr) { | |
| 232 | 238 | console.error("[config] postMessage threw", postErr); | |
| 233 | 239 | throw postErr; | |
| 234 | 240 | } | |
| 235 | - | } catch (e: any) { | |
| 241 | + | } catch (e: unknown) { | |
| 236 | 242 | console.error("[config] save exception", e); | |
| 237 | - | saveError = e.message; | |
| 243 | + | saveError = getErrorMessage(e); | |
| 238 | 244 | saving = false; | |
| 239 | 245 | } | |
| 240 | 246 | } | |
| @@ -246,8 +252,8 @@ | |||
| 246 | 252 | ||
| 247 | 253 | onMount(() => { | |
| 248 | 254 | console.debug("[config] mount", { | |
| 249 | - | webviewId: (window as any).__dumber_webview_id, | |
| 250 | - | hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber), | |
| 255 | + | webviewId: window.__dumber_webview_id, | |
| 256 | + | hasBridge: Boolean(window.webkit?.messageHandlers?.dumber), | |
| 251 | 257 | }); | |
| 252 | 258 | ||
| 253 | 259 | const onThemeChanged = (e: Event) => { | |
| @@ -298,6 +304,7 @@ | |||
| 298 | 304 | <Tabs.List class="bg-muted/60"> | |
| 299 | 305 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | |
| 300 | 306 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | |
| 307 | + | <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger> | |
| 301 | 308 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | |
| 302 | 309 | </Tabs.List> | |
| 303 | 310 | </div> | |
| @@ -340,7 +347,12 @@ | |||
| 340 | 347 | </div> | |
| 341 | 348 | <div class="space-y-2"> | |
| 342 | 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 | 356 | </div> | |
| 345 | 357 | </div> | |
| 346 | 358 | ||
| @@ -424,6 +436,10 @@ | |||
| 424 | 436 | </Card.Root> | |
| 425 | 437 | </Tabs.Content> | |
| 426 | 438 | ||
| 439 | + | <Tabs.Content value="keybindings"> | |
| 440 | + | <KeybindingsTab /> | |
| 441 | + | </Tabs.Content> | |
| 442 | + | ||
| 427 | 443 | <Tabs.Content value="performance"> | |
| 428 | 444 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 429 | 445 | <Card.Header> | |
| @@ -614,35 +630,37 @@ | |||
| 614 | 630 | <span>Theme x{themeEvents}</span> | |
| 615 | 631 | {/if} | |
| 616 | 632 | </div> | |
| 617 | - | <div class="flex items-center gap-3"> | |
| 618 | - | <AlertDialog.Root bind:open={resetDialogOpen}> | |
| 619 | - | <AlertDialog.Trigger disabled={saving}> | |
| 620 | - | {#snippet child({ props })} | |
| 621 | - | <Button variant="outline" {...props} type="button"> | |
| 622 | - | Reset Defaults | |
| 623 | - | </Button> | |
| 624 | - | {/snippet} | |
| 625 | - | </AlertDialog.Trigger> | |
| 626 | - | <AlertDialog.Content> | |
| 627 | - | <AlertDialog.Header> | |
| 628 | - | <AlertDialog.Title>Reset to defaults?</AlertDialog.Title> | |
| 629 | - | <AlertDialog.Description> | |
| 630 | - | This will reset all settings on this page to their default values. | |
| 631 | - | You will still need to click Save to persist the changes. | |
| 632 | - | </AlertDialog.Description> | |
| 633 | - | </AlertDialog.Header> | |
| 634 | - | <AlertDialog.Footer> | |
| 635 | - | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |
| 636 | - | <AlertDialog.Action onclick={doResetToDefaults}> | |
| 637 | - | Reset Defaults | |
| 638 | - | </AlertDialog.Action> | |
| 639 | - | </AlertDialog.Footer> | |
| 640 | - | </AlertDialog.Content> | |
| 641 | - | </AlertDialog.Root> | |
| 642 | - | <Button onclick={saveConfig} disabled={saving} type="button"> | |
| 643 | - | {saving ? "Saving…" : "Save"} | |
| 644 | - | </Button> | |
| 645 | - | </div> | |
| 633 | + | {#if activeTab !== "keybindings"} | |
| 634 | + | <div class="flex items-center gap-3"> | |
| 635 | + | <AlertDialog.Root bind:open={resetDialogOpen}> | |
| 636 | + | <AlertDialog.Trigger disabled={saving}> | |
| 637 | + | {#snippet child({ props })} | |
| 638 | + | <Button variant="outline" {...props} type="button"> | |
| 639 | + | Reset Defaults | |
| 640 | + | </Button> | |
| 641 | + | {/snippet} | |
| 642 | + | </AlertDialog.Trigger> | |
| 643 | + | <AlertDialog.Content> | |
| 644 | + | <AlertDialog.Header> | |
| 645 | + | <AlertDialog.Title>Reset to defaults?</AlertDialog.Title> | |
| 646 | + | <AlertDialog.Description> | |
| 647 | + | This will reset all settings on this page to their default values. | |
| 648 | + | You will still need to click Save to persist the changes. | |
| 649 | + | </AlertDialog.Description> | |
| 650 | + | </AlertDialog.Header> | |
| 651 | + | <AlertDialog.Footer> | |
| 652 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |
| 653 | + | <AlertDialog.Action onclick={doResetToDefaults}> | |
| 654 | + | Reset Defaults | |
| 655 | + | </AlertDialog.Action> | |
| 656 | + | </AlertDialog.Footer> | |
| 657 | + | </AlertDialog.Content> | |
| 658 | + | </AlertDialog.Root> | |
| 659 | + | <Button onclick={saveConfig} disabled={saving} type="button"> | |
| 660 | + | {saving ? "Saving…" : "Save"} | |
| 661 | + | </Button> | |
| 662 | + | </div> | |
| 663 | + | {/if} | |
| 646 | 664 | </div> | |
| 647 | 665 | ||
| 648 | 666 | <!-- Save error display --> | |
M webui/src/pages/ErrorPage.svelte +11 -2
| @@ -70,7 +70,11 @@ | |||
| 70 | 70 | const handler = (event: MessageEvent) => { | |
| 71 | 71 | if (event.data?.requestId === requestId) { | |
| 72 | 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 | 80 | window.addEventListener('message', handler); | |
| @@ -110,7 +114,11 @@ | |||
| 110 | 114 | } | |
| 111 | 115 | ||
| 112 | 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 | 124 | function goBack() { | |
| @@ -144,6 +152,7 @@ | |||
| 144 | 152 | <Card.Header class="text-center"> | |
| 145 | 153 | <div class="flex justify-center mb-4"> | |
| 146 | 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 | 156 | {@html icons[config.icon] || icons.error} | |
| 148 | 157 | </svg> | |
| 149 | 158 | </div> | |
M webui/src/pages/config/ConfigShell.svelte +3 -4
| @@ -9,14 +9,12 @@ | |||
| 9 | 9 | let { children }: Props = $props(); | |
| 10 | 10 | ||
| 11 | 11 | function navigateWithViewTransition(url: string) { | |
| 12 | - | const doc = document as any; | |
| 13 | - | ||
| 14 | - | if (typeof doc?.startViewTransition !== 'function') { | |
| 12 | + | if (typeof document.startViewTransition !== 'function') { | |
| 15 | 13 | window.location.href = url; | |
| 16 | 14 | return; | |
| 17 | 15 | } | |
| 18 | 16 | ||
| 19 | - | const transition = doc.startViewTransition(() => { | |
| 17 | + | const transition = document.startViewTransition(() => { | |
| 20 | 18 | document.documentElement.dataset.vt = 'leaving'; | |
| 21 | 19 | }); | |
| 22 | 20 | ||
| @@ -34,6 +32,7 @@ | |||
| 34 | 32 | <svelte:head> | |
| 35 | 33 | <title>dumb://config</title> | |
| 36 | 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 | 36 | {@html `<style> | |
| 38 | 37 | html, body { margin: 0; padding: 0; } | |
| 39 | 38 | html { background: var(--background, #0a0a0a); } | |
A webui/src/pages/config/KeyCaptureModal.svelte +184 -0
| @@ -0,0 +1,184 @@ | |||
| 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 | + | // Ignore modifier-only presses | |
| 50 | + | if (["control", "alt", "shift", "meta"].includes(e.key.toLowerCase())) { | |
| 51 | + | return; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Escape closes the modal | |
| 55 | + | if (e.key === "Escape") { | |
| 56 | + | onCancel(); | |
| 57 | + | return; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Normalize key name | |
| 61 | + | const key = e.key === " " ? "space" : e.key.toLowerCase(); | |
| 62 | + | ||
| 63 | + | // Only add shift modifier for letter keys and non-symbol keys | |
| 64 | + | // Symbol keys like { } + etc. are already the shifted result | |
| 65 | + | if (e.shiftKey && !SHIFTED_SYMBOLS.has(e.key)) { | |
| 66 | + | parts.push("shift"); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | parts.push(key); | |
| 70 | + | capturedKey = parts.join("+"); | |
| 71 | + | isCapturing = false; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | function addCapturedKey() { | |
| 75 | + | if (capturedKey && !newKeys.includes(capturedKey)) { | |
| 76 | + | newKeys = [...newKeys, capturedKey]; | |
| 77 | + | } | |
| 78 | + | capturedKey = null; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | function removeKey(index: number) { | |
| 82 | + | newKeys = newKeys.filter((_, i) => i !== index); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | function handleSave() { | |
| 86 | + | // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers | |
| 87 | + | const keysToSave = $state.snapshot(newKeys) as string[]; | |
| 88 | + | // Auto-add any pending captured key before saving | |
| 89 | + | if (capturedKey && !keysToSave.includes(capturedKey)) { | |
| 90 | + | keysToSave.push(capturedKey); | |
| 91 | + | } | |
| 92 | + | isSaving = true; | |
| 93 | + | try { | |
| 94 | + | onSave(keysToSave); | |
| 95 | + | } finally { | |
| 96 | + | // Reset isSaving so modal remains dismissible if save fails | |
| 97 | + | isSaving = false; | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | onMount(() => { | |
| 102 | + | const handler = (e: KeyboardEvent) => handleKeyDown(e); | |
| 103 | + | window.addEventListener("keydown", handler, true); | |
| 104 | + | return () => window.removeEventListener("keydown", handler, true); | |
| 105 | + | }); | |
| 106 | + | </script> | |
| 107 | + | ||
| 108 | + | <AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}> | |
| 109 | + | <AlertDialog.Content class="max-w-md"> | |
| 110 | + | <AlertDialog.Header> | |
| 111 | + | <AlertDialog.Title>Edit Keybinding</AlertDialog.Title> | |
| 112 | + | <AlertDialog.Description> | |
| 113 | + | Configure keybindings for <strong>{binding.action}</strong> | |
| 114 | + | </AlertDialog.Description> | |
| 115 | + | </AlertDialog.Header> | |
| 116 | + | ||
| 117 | + | <div class="space-y-4 py-4"> | |
| 118 | + | <!-- Current bindings --> | |
| 119 | + | <div class="space-y-2"> | |
| 120 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 121 | + | Current Bindings | |
| 122 | + | </div> | |
| 123 | + | <div class="flex flex-wrap gap-2"> | |
| 124 | + | {#each newKeys as key, i (key)} | |
| 125 | + | <div class="flex items-center gap-1 rounded bg-muted px-2 py-1"> | |
| 126 | + | <kbd class="text-xs font-mono">{formatKey(key)}</kbd> | |
| 127 | + | <button | |
| 128 | + | type="button" | |
| 129 | + | onclick={() => removeKey(i)} | |
| 130 | + | class="text-muted-foreground hover:text-foreground" | |
| 131 | + | > | |
| 132 | + | <X class="size-3" /> | |
| 133 | + | </button> | |
| 134 | + | </div> | |
| 135 | + | {/each} | |
| 136 | + | {#if newKeys.length === 0} | |
| 137 | + | <span class="text-sm text-muted-foreground">(no bindings)</span> | |
| 138 | + | {/if} | |
| 139 | + | </div> | |
| 140 | + | </div> | |
| 141 | + | ||
| 142 | + | <!-- Capture area --> | |
| 143 | + | <div class="space-y-2"> | |
| 144 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 145 | + | Add New Binding | |
| 146 | + | </div> | |
| 147 | + | <div class="flex gap-2"> | |
| 148 | + | <button | |
| 149 | + | type="button" | |
| 150 | + | onclick={() => { isCapturing = true; capturedKey = null; }} | |
| 151 | + | 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' : ''}" | |
| 152 | + | > | |
| 153 | + | <Keyboard class="size-5" /> | |
| 154 | + | {#if isCapturing} | |
| 155 | + | Press a key combination... | |
| 156 | + | {:else if capturedKey} | |
| 157 | + | <kbd class="font-mono">{formatKey(capturedKey)}</kbd> | |
| 158 | + | {:else} | |
| 159 | + | Click to capture key | |
| 160 | + | {/if} | |
| 161 | + | </button> | |
| 162 | + | {#if capturedKey} | |
| 163 | + | <Button variant="outline" onclick={addCapturedKey}> | |
| 164 | + | <Plus class="mr-1 size-4" /> | |
| 165 | + | Add | |
| 166 | + | </Button> | |
| 167 | + | {/if} | |
| 168 | + | </div> | |
| 169 | + | </div> | |
| 170 | + | ||
| 171 | + | <!-- Default hint --> | |
| 172 | + | {#if binding.default_keys.length > 0} | |
| 173 | + | <div class="text-xs text-muted-foreground"> | |
| 174 | + | Default: {binding.default_keys.map(formatKey).join(", ")} | |
| 175 | + | </div> | |
| 176 | + | {/if} | |
| 177 | + | </div> | |
| 178 | + | ||
| 179 | + | <AlertDialog.Footer> | |
| 180 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |
| 181 | + | <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action> | |
| 182 | + | </AlertDialog.Footer> | |
| 183 | + | </AlertDialog.Content> | |
| 184 | + | </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 | 20 | // Convert data to Frappe Charts format (ensure all 24 hours) | |
| 21 | 21 | const chartData = $derived.by(() => { | |
| 22 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation | |
| 22 | 23 | const visitsByHour = new Map<number, number>(); | |
| 23 | 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 | 139 | <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label> | |
| 140 | 140 | <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}> | |
| 141 | 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 | 143 | <option value={key}>{key}</option> | |
| 144 | 144 | {/each} | |
| 145 | 145 | </select> | |
M webui/src/pages/homepage/messaging.ts +34 -27
| @@ -23,8 +23,12 @@ import { homepageState } from './state.svelte'; | |||
| 23 | 23 | // REQUEST TRACKING | |
| 24 | 24 | // ═══════════════════════════════════════════════════════════════════════════════ | |
| 25 | 25 | ||
| 26 | - | interface PendingRequest<T = unknown> { | |
| 27 | - | resolve: (value: T) => void; | |
| 26 | + | // Pending requests store resolvers for different response types. | |
| 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 | 32 | reject: (error: Error) => void; | |
| 29 | 33 | timeout: ReturnType<typeof setTimeout>; | |
| 30 | 34 | } | |
| @@ -38,8 +42,8 @@ let callbacksInitialized = false; | |||
| 38 | 42 | // WEBKIT BRIDGE UTILITIES | |
| 39 | 43 | // ═══════════════════════════════════════════════════════════════════════════════ | |
| 40 | 44 | ||
| 41 | - | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |
| 42 | - | const bridge = (window as any).webkit?.messageHandlers?.dumber; | |
| 45 | + | function getWebKitBridge(): DumberBridge | null { | |
| 46 | + | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 43 | 47 | if (bridge && typeof bridge.postMessage === 'function') { | |
| 44 | 48 | return bridge; | |
| 45 | 49 | } | |
| @@ -47,7 +51,7 @@ function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |||
| 47 | 51 | } | |
| 48 | 52 | ||
| 49 | 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 | 57 | function generateRequestId(type: string): string { | |
| @@ -62,9 +66,7 @@ function initializeCallbacks(): void { | |||
| 62 | 66 | if (callbacksInitialized) return; | |
| 63 | 67 | ||
| 64 | 68 | // Generic response handler - accepts a single Response object from Go backend | |
| 65 | - | (window as any).__dumber_homepage_response = ( | |
| 66 | - | response: { requestId: string; success: boolean; data?: unknown; error?: string } | |
| 67 | - | ) => { | |
| 69 | + | window.__dumber_homepage_response = (response) => { | |
| 68 | 70 | const request = pendingRequests.get(response.requestId); | |
| 69 | 71 | if (!request) return; | |
| 70 | 72 | ||
| @@ -74,61 +76,60 @@ function initializeCallbacks(): void { | |||
| 74 | 76 | if (response.success) { | |
| 75 | 77 | request.resolve(response.data); | |
| 76 | 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 | 83 | // Legacy callbacks for backward compatibility | |
| 82 | 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 | 86 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 85 | 87 | }; | |
| 86 | 88 | ||
| 87 | - | (window as any).__dumber_folders = (dataOrResponse: unknown) => { | |
| 89 | + | window.__dumber_folders = (dataOrResponse) => { | |
| 88 | 90 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 89 | 91 | }; | |
| 90 | 92 | ||
| 91 | - | (window as any).__dumber_tags = (dataOrResponse: unknown) => { | |
| 93 | + | window.__dumber_tags = (dataOrResponse) => { | |
| 92 | 94 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 93 | 95 | }; | |
| 94 | 96 | ||
| 95 | - | (window as any).__dumber_favorites = (dataOrResponse: unknown) => { | |
| 97 | + | window.__dumber_favorites = (dataOrResponse) => { | |
| 96 | 98 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 97 | 99 | }; | |
| 98 | 100 | ||
| 99 | - | (window as any).__dumber_analytics = (dataOrResponse: unknown) => { | |
| 101 | + | window.__dumber_analytics = (dataOrResponse) => { | |
| 100 | 102 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 101 | 103 | }; | |
| 102 | 104 | ||
| 103 | - | (window as any).__dumber_domain_stats = (dataOrResponse: unknown) => { | |
| 105 | + | window.__dumber_domain_stats = (dataOrResponse) => { | |
| 104 | 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 | 110 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 109 | 111 | }; | |
| 110 | 112 | ||
| 111 | - | (window as any).__dumber_history_deleted = (dataOrResponse: unknown) => { | |
| 113 | + | window.__dumber_history_deleted = (dataOrResponse) => { | |
| 112 | 114 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 113 | 115 | }; | |
| 114 | 116 | ||
| 115 | - | (window as any).__dumber_history_cleared = (dataOrResponse: unknown) => { | |
| 117 | + | window.__dumber_history_cleared = (dataOrResponse) => { | |
| 116 | 118 | handleLegacyOrResponseCallback(dataOrResponse); | |
| 117 | 119 | }; | |
| 118 | 120 | ||
| 119 | - | (window as any).__dumber_error = (errorOrResponse: unknown) => { | |
| 121 | + | window.__dumber_error = (errorOrResponse) => { | |
| 120 | 122 | // Handle both formats: direct error string or Response object with error | |
| 121 | - | const response = errorOrResponse as { requestId?: string; error?: string } | string; | |
| 122 | - | if (typeof response === 'string') { | |
| 123 | + | if (typeof errorOrResponse === 'string') { | |
| 123 | 124 | // Legacy format: direct error string | |
| 124 | - | console.error('[messaging] Legacy error:', response); | |
| 125 | - | } else if (response && typeof response === 'object') { | |
| 125 | + | console.error('[messaging] Legacy error:', errorOrResponse); | |
| 126 | + | } else if (errorOrResponse && typeof errorOrResponse === 'object') { | |
| 126 | 127 | // Response object format | |
| 127 | - | const request = pendingRequests.get(response.requestId || 'default'); | |
| 128 | + | const request = pendingRequests.get(errorOrResponse.requestId ?? 'default'); | |
| 128 | 129 | if (request) { | |
| 129 | 130 | clearTimeout(request.timeout); | |
| 130 | - | pendingRequests.delete(response.requestId || 'default'); | |
| 131 | - | request.reject(new Error(response.error || 'Unknown error')); | |
| 131 | + | pendingRequests.delete(errorOrResponse.requestId ?? 'default'); | |
| 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 | 189 | reject(new Error(`Request timeout: ${type}`)); | |
| 189 | 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 | 200 | try { | |
| 194 | 201 | bridge.postMessage({ | |
M webui/src/pages/homepage/panels/HistoryPanel.svelte +2 -0
| @@ -37,6 +37,7 @@ | |||
| 37 | 37 | }); | |
| 38 | 38 | ||
| 39 | 39 | // Recompute timeline from filtered entries | |
| 40 | + | /* eslint-disable svelte/prefer-svelte-reactivity -- temporary Map/Date for data grouping, not reactive state */ | |
| 40 | 41 | const displayedTimeline = $derived(() => { | |
| 41 | 42 | const entries = displayedEntries(); | |
| 42 | 43 | if (entries.length === 0) return []; | |
| @@ -78,6 +79,7 @@ | |||
| 78 | 79 | ||
| 79 | 80 | return result.sort((a, b) => b.date.localeCompare(a.date)); | |
| 80 | 81 | }); | |
| 82 | + | /* eslint-enable svelte/prefer-svelte-reactivity */ | |
| 81 | 83 | ||
| 82 | 84 | const handleSelectEntry = (entry: HistoryEntry) => { | |
| 83 | 85 | navigateTo(entry.url); | |
M webui/src/pages/homepage/shell/CommandPalette.svelte +1 -1
| @@ -165,7 +165,7 @@ | |||
| 165 | 165 | ||
| 166 | 166 | // Reset selection when query changes | |
| 167 | 167 | $effect(() => { | |
| 168 | - | query; // Track query | |
| 168 | + | void query; // Track query changes | |
| 169 | 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 | 37 | function navigateWithViewTransition(url: string) { | |
| 38 | - | const doc = document as any; | |
| 39 | - | ||
| 40 | - | if (typeof doc?.startViewTransition !== 'function') { | |
| 38 | + | if (typeof document.startViewTransition !== 'function') { | |
| 41 | 39 | window.location.href = url; | |
| 42 | 40 | return; | |
| 43 | 41 | } | |
| 44 | 42 | ||
| 45 | - | const transition = doc.startViewTransition(() => { | |
| 43 | + | const transition = document.startViewTransition(() => { | |
| 46 | 44 | document.documentElement.dataset.vt = 'leaving'; | |
| 47 | 45 | }); | |
| 48 | 46 | ||
| @@ -53,17 +51,15 @@ | |||
| 53 | 51 | ||
| 54 | 52 | const toggleTheme = () => { | |
| 55 | 53 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | |
| 56 | - | const manager = (window as any).__dumber_color_scheme_manager as | |
| 57 | - | | { setUserPreference?: (theme: ThemeMode) => void } | |
| 58 | - | | undefined; | |
| 54 | + | const manager = window.__dumber_color_scheme_manager; | |
| 59 | 55 | ||
| 60 | 56 | if (manager?.setUserPreference) { | |
| 61 | 57 | manager.setUserPreference(nextMode); | |
| 62 | 58 | return; | |
| 63 | 59 | } | |
| 64 | 60 | ||
| 65 | - | if ((window as any).__dumber_setTheme) { | |
| 66 | - | (window as any).__dumber_setTheme(nextMode); | |
| 61 | + | if (window.__dumber_setTheme) { | |
| 62 | + | window.__dumber_setTheme(nextMode); | |
| 67 | 63 | localStorage.setItem('dumber.theme', nextMode); | |
| 68 | 64 | return; | |
| 69 | 65 | } | |
| @@ -109,6 +105,7 @@ | |||
| 109 | 105 | <svelte:head> | |
| 110 | 106 | <title>dumb://home</title> | |
| 111 | 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 | 109 | {@html `<style> | |
| 113 | 110 | html, body { margin: 0; padding: 0; } | |
| 114 | 111 | html { background: var(--background, #0a0a0a); } | |
M webui/src/pages/homepage/shell/KeyboardHints.svelte +4 -4
| @@ -136,7 +136,7 @@ | |||
| 136 | 136 | {#each navHints as hint (hint.description)} | |
| 137 | 137 | <div class="hint-row"> | |
| 138 | 138 | <div class="hint-keys"> | |
| 139 | - | {#each hint.keys as key, i} | |
| 139 | + | {#each hint.keys as key, i (key)} | |
| 140 | 140 | {#if i > 0}<span class="key-sep">/</span>{/if} | |
| 141 | 141 | <kbd>{key}</kbd> | |
| 142 | 142 | {/each} | |
| @@ -157,7 +157,7 @@ | |||
| 157 | 157 | {#each panelSwitchHints as hint (hint.description)} | |
| 158 | 158 | <div class="hint-row"> | |
| 159 | 159 | <div class="hint-keys"> | |
| 160 | - | {#each hint.keys as key, i} | |
| 160 | + | {#each hint.keys as key, i (key)} | |
| 161 | 161 | {#if i > 0}<span class="key-sep">/</span>{/if} | |
| 162 | 162 | <kbd>{key}</kbd> | |
| 163 | 163 | {/each} | |
| @@ -179,7 +179,7 @@ | |||
| 179 | 179 | {#each panelHints as hint (hint.description)} | |
| 180 | 180 | <div class="hint-row"> | |
| 181 | 181 | <div class="hint-keys"> | |
| 182 | - | {#each hint.keys as key, i} | |
| 182 | + | {#each hint.keys as key, i (key)} | |
| 183 | 183 | {#if i > 0}<span class="key-sep">/</span>{/if} | |
| 184 | 184 | <kbd>{key}</kbd> | |
| 185 | 185 | {/each} | |
| @@ -201,7 +201,7 @@ | |||
| 201 | 201 | {#each quickAccessHints as hint (hint.description)} | |
| 202 | 202 | <div class="hint-row"> | |
| 203 | 203 | <div class="hint-keys"> | |
| 204 | - | {#each hint.keys as key, i} | |
| 204 | + | {#each hint.keys as key, i (key)} | |
| 205 | 205 | {#if i > 0}<span class="key-sep">/</span>{/if} | |
| 206 | 206 | <kbd>{key}</kbd> | |
| 207 | 207 | {/each} | |
M webui/src/pages/homepage/shell/StatusBar.svelte +2 -1
| @@ -38,7 +38,7 @@ | |||
| 38 | 38 | label: 'entries', | |
| 39 | 39 | filtered: false, | |
| 40 | 40 | }; | |
| 41 | - | case 'favorites': | |
| 41 | + | case 'favorites': { | |
| 42 | 42 | const filtered = homepageState.filteredFavorites; | |
| 43 | 43 | const total = homepageState.favorites.length; | |
| 44 | 44 | return { | |
| @@ -46,6 +46,7 @@ | |||
| 46 | 46 | label: filtered.length === total ? 'items' : `of ${total}`, | |
| 47 | 47 | filtered: filtered.length !== total, | |
| 48 | 48 | }; | |
| 49 | + | } | |
| 49 | 50 | case 'analytics': | |
| 50 | 51 | return { | |
| 51 | 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 | 71 | // HELPER FUNCTIONS | |
| 72 | 72 | // ═══════════════════════════════════════════════════════════════════════════════ | |
| 73 | 73 | ||
| 74 | + | /* eslint-disable svelte/prefer-svelte-reactivity -- helper function uses temporary Map/Date, not reactive state */ | |
| 74 | 75 | function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | |
| 75 | 76 | const groups = new Map<string, HistoryEntry[]>(); | |
| 76 | 77 | const today = new Date(); | |
| @@ -109,6 +110,7 @@ function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | |||
| 109 | 110 | ||
| 110 | 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 | 116 | // EXPORTED STORE | |
M webui/src/pages/homepage/types.ts +3 -1
| @@ -3,6 +3,8 @@ | |||
| 3 | 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 | 9 | // History Types | |
| 8 | 10 | // ───────────────────────────────────────────────────────────────────────────── | |
| @@ -110,7 +112,7 @@ export interface CommandPaletteItem { | |||
| 110 | 112 | id: string; | |
| 111 | 113 | label: string; | |
| 112 | 114 | description?: string; | |
| 113 | - | icon?: import('svelte').Component | string; | |
| 115 | + | icon?: Component | string; | |
| 114 | 116 | action: () => void; | |
| 115 | 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 {}; | |