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 | interfaces: | 10 | interfaces: | ||
| 11 | ConfigMigrator: {} | 11 | ConfigMigrator: {} | ||
| 12 | ConfigSchemaProvider: {} | 12 | ConfigSchemaProvider: {} | ||
| 13 | + | ConfigTransformer: {} | |||
| 13 | DatabaseProvider: {} | 14 | DatabaseProvider: {} | ||
| 14 | DiffFormatter: {} | 15 | DiffFormatter: {} | ||
| 15 | SessionLogger: {} | 16 | SessionLogger: {} | ||
| @@ -27,6 +28,9 @@ packages: | |||||
| 27 | FocusedInputProvider: {} | 28 | FocusedInputProvider: {} | ||
| 28 | AccentPickerUI: {} | 29 | AccentPickerUI: {} | ||
| 29 | FontDetector: {} | 30 | FontDetector: {} | ||
| 31 | + | KeybindingsProvider: {} | |||
| 32 | + | KeybindingsSaver: {} | |||
| 33 | + | DownloadResponse: {} | |||
| 30 | github.com/bnema/dumber/internal/domain/repository: | 34 | github.com/bnema/dumber/internal/domain/repository: | ||
| 31 | config: | 35 | config: | ||
| 32 | dir: '{{.InterfaceDir}}/mocks' | 36 | dir: '{{.InterfaceDir}}/mocks' | ||
M CHANGELOG.md +30 -1
| @@ -2,12 +2,41 @@ | |||||
| 2 | 2 | ||||
| 3 | All notable changes to this project will be documented in this file. | 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 | ### Fixed | 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 | - **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. | 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 | - **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. | 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 | - **Noisy gesture navigation logs**: Mouse button back/forward navigation errors (expected when no history) now log at debug level instead of error level. | 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 | ## [0.25.0] - 2026-01-08 | 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 | | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory | | 139 | | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory | | ||
| 140 | | `logging.enable_file_log` | bool | `true` | - | Enable file logging | | 140 | | `logging.enable_file_log` | bool | `true` | - | Enable file logging | | ||
| 141 | | `logging.capture_console` | bool | `false` | - | Capture browser console to logs | | 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 | ## Appearance | 144 | ## Appearance | ||
| 144 | 145 | ||||
| @@ -236,10 +237,10 @@ move-pane-to-tab = ["m"] | |||||
| 236 | move-pane-to-next-tab = ["M", "shift+m"] | 237 | move-pane-to-next-tab = ["M", "shift+m"] | ||
| 237 | 238 | ||||
| 238 | # Consume-or-expel (niri-style) - very alpha | 239 | # Consume-or-expel (niri-style) - very alpha | ||
| 239 | - | consume-or-expel-left = ["bracketleft", "["] | 240 | + | consume-or-expel-left = ["["] |
| 240 | - | consume-or-expel-right = ["bracketright", "]"] | 241 | + | consume-or-expel-right = ["]"] |
| 241 | - | consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"] | 242 | + | consume-or-expel-up = ["{"] |
| 242 | - | consume-or-expel-down = ["shift+bracketright", "braceright", "}"] | 243 | + | consume-or-expel-down = ["}"] |
| 243 | 244 | ||||
| 244 | focus-right = ["shift+arrowright", "shift+l"] | 245 | focus-right = ["shift+arrowright", "shift+l"] | ||
| 245 | focus-left = ["shift+arrowleft", "shift+h"] | 246 | focus-left = ["shift+arrowleft", "shift+h"] | ||
| @@ -307,16 +308,31 @@ Notes: | |||||
| 307 | 308 | ||||
| 308 | ### Global Shortcuts | 309 | ### Global Shortcuts | ||
| 309 | 310 | ||||
| 310 | - | | Key | Type | Default | Description | | 311 | + | Global shortcuts are configured under `workspace.shortcuts.actions` using the same `ActionBinding` structure as modal keybindings: |
| 311 | - | |-----|------|---------|-------------| | 312 | + | |
| 312 | - | | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) | | 313 | + | | Action | Default Keys | Description | |
| 313 | - | | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | 314 | + | |--------|--------------|-------------| |
| 314 | - | | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | 315 | + | | `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) | |
| 315 | - | | `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) | | 316 | + | | `next_tab` | `ctrl+tab` | Switch to next tab | |
| 316 | - | | `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) | | 317 | + | | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab | |
| 317 | - | | `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) | | 318 | + | | `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked | |
| 318 | - | | `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) | | 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 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | 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 | > **Note:** Consume-or-expel is experimental and may change behavior between releases. | 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 | type DownloadEventHandler interface { | 26 | type DownloadEventHandler interface { | ||
| 27 | OnDownloadEvent(ctx context.Context, event DownloadEvent) | 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 | return _c | 1037 | return _c | ||
| 1038 | } | 1038 | } | ||
| 1039 | 1039 | ||||
| 1040 | + | // NewMockConfigTransformer creates a new instance of MockConfigTransformer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1041 | + | // The first argument is typically a *testing.T value. | |||
| 1042 | + | func NewMockConfigTransformer(t interface { | |||
| 1043 | + | mock.TestingT | |||
| 1044 | + | Cleanup(func()) | |||
| 1045 | + | }) *MockConfigTransformer { | |||
| 1046 | + | mock := &MockConfigTransformer{} | |||
| 1047 | + | mock.Mock.Test(t) | |||
| 1048 | + | ||||
| 1049 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1050 | + | ||||
| 1051 | + | return mock | |||
| 1052 | + | } | |||
| 1053 | + | ||||
| 1054 | + | // MockConfigTransformer is an autogenerated mock type for the ConfigTransformer type | |||
| 1055 | + | type MockConfigTransformer struct { | |||
| 1056 | + | mock.Mock | |||
| 1057 | + | } | |||
| 1058 | + | ||||
| 1059 | + | type MockConfigTransformer_Expecter struct { | |||
| 1060 | + | mock *mock.Mock | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | func (_m *MockConfigTransformer) EXPECT() *MockConfigTransformer_Expecter { | |||
| 1064 | + | return &MockConfigTransformer_Expecter{mock: &_m.Mock} | |||
| 1065 | + | } | |||
| 1066 | + | ||||
| 1067 | + | // TransformLegacyActions provides a mock function for the type MockConfigTransformer | |||
| 1068 | + | func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |||
| 1069 | + | _mock.Called(rawConfig) | |||
| 1070 | + | return | |||
| 1071 | + | } | |||
| 1072 | + | ||||
| 1073 | + | // MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions' | |||
| 1074 | + | type MockConfigTransformer_TransformLegacyActions_Call struct { | |||
| 1075 | + | *mock.Call | |||
| 1076 | + | } | |||
| 1077 | + | ||||
| 1078 | + | // TransformLegacyActions is a helper method to define mock.On call | |||
| 1079 | + | // - rawConfig map[string]any | |||
| 1080 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1081 | + | return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)} | |||
| 1082 | + | } | |||
| 1083 | + | ||||
| 1084 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1085 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1086 | + | var arg0 map[string]any | |||
| 1087 | + | if args[0] != nil { | |||
| 1088 | + | arg0 = args[0].(map[string]any) | |||
| 1089 | + | } | |||
| 1090 | + | run( | |||
| 1091 | + | arg0, | |||
| 1092 | + | ) | |||
| 1093 | + | }) | |||
| 1094 | + | return _c | |||
| 1095 | + | } | |||
| 1096 | + | ||||
| 1097 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1098 | + | _c.Call.Return() | |||
| 1099 | + | return _c | |||
| 1100 | + | } | |||
| 1101 | + | ||||
| 1102 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |||
| 1103 | + | _c.Run(run) | |||
| 1104 | + | return _c | |||
| 1105 | + | } | |||
| 1106 | + | ||||
| 1040 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1107 | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1041 | // The first argument is typically a *testing.T value. | 1108 | // The first argument is typically a *testing.T value. | ||
| 1042 | func NewMockDatabaseProvider(t interface { | 1109 | func NewMockDatabaseProvider(t interface { | ||
| @@ -1495,6 +1562,425 @@ func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Co | |||||
| 1495 | return _c | 1562 | return _c | ||
| 1496 | } | 1563 | } | ||
| 1497 | 1564 | ||||
| 1565 | + | // NewMockKeybindingsProvider creates a new instance of MockKeybindingsProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1566 | + | // The first argument is typically a *testing.T value. | |||
| 1567 | + | func NewMockKeybindingsProvider(t interface { | |||
| 1568 | + | mock.TestingT | |||
| 1569 | + | Cleanup(func()) | |||
| 1570 | + | }) *MockKeybindingsProvider { | |||
| 1571 | + | mock := &MockKeybindingsProvider{} | |||
| 1572 | + | mock.Mock.Test(t) | |||
| 1573 | + | ||||
| 1574 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1575 | + | ||||
| 1576 | + | return mock | |||
| 1577 | + | } | |||
| 1578 | + | ||||
| 1579 | + | // MockKeybindingsProvider is an autogenerated mock type for the KeybindingsProvider type | |||
| 1580 | + | type MockKeybindingsProvider struct { | |||
| 1581 | + | mock.Mock | |||
| 1582 | + | } | |||
| 1583 | + | ||||
| 1584 | + | type MockKeybindingsProvider_Expecter struct { | |||
| 1585 | + | mock *mock.Mock | |||
| 1586 | + | } | |||
| 1587 | + | ||||
| 1588 | + | func (_m *MockKeybindingsProvider) EXPECT() *MockKeybindingsProvider_Expecter { | |||
| 1589 | + | return &MockKeybindingsProvider_Expecter{mock: &_m.Mock} | |||
| 1590 | + | } | |||
| 1591 | + | ||||
| 1592 | + | // CheckConflicts provides a mock function for the type MockKeybindingsProvider | |||
| 1593 | + | func (_mock *MockKeybindingsProvider) CheckConflicts(ctx context.Context, mode string, action string, keys []string) ([]port.KeybindingConflict, error) { | |||
| 1594 | + | ret := _mock.Called(ctx, mode, action, keys) | |||
| 1595 | + | ||||
| 1596 | + | if len(ret) == 0 { | |||
| 1597 | + | panic("no return value specified for CheckConflicts") | |||
| 1598 | + | } | |||
| 1599 | + | ||||
| 1600 | + | var r0 []port.KeybindingConflict | |||
| 1601 | + | var r1 error | |||
| 1602 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) ([]port.KeybindingConflict, error)); ok { | |||
| 1603 | + | return returnFunc(ctx, mode, action, keys) | |||
| 1604 | + | } | |||
| 1605 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) []port.KeybindingConflict); ok { | |||
| 1606 | + | r0 = returnFunc(ctx, mode, action, keys) | |||
| 1607 | + | } else { | |||
| 1608 | + | if ret.Get(0) != nil { | |||
| 1609 | + | r0 = ret.Get(0).([]port.KeybindingConflict) | |||
| 1610 | + | } | |||
| 1611 | + | } | |||
| 1612 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []string) error); ok { | |||
| 1613 | + | r1 = returnFunc(ctx, mode, action, keys) | |||
| 1614 | + | } else { | |||
| 1615 | + | r1 = ret.Error(1) | |||
| 1616 | + | } | |||
| 1617 | + | return r0, r1 | |||
| 1618 | + | } | |||
| 1619 | + | ||||
| 1620 | + | // MockKeybindingsProvider_CheckConflicts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckConflicts' | |||
| 1621 | + | type MockKeybindingsProvider_CheckConflicts_Call struct { | |||
| 1622 | + | *mock.Call | |||
| 1623 | + | } | |||
| 1624 | + | ||||
| 1625 | + | // CheckConflicts is a helper method to define mock.On call | |||
| 1626 | + | // - ctx context.Context | |||
| 1627 | + | // - mode string | |||
| 1628 | + | // - action string | |||
| 1629 | + | // - keys []string | |||
| 1630 | + | func (_e *MockKeybindingsProvider_Expecter) CheckConflicts(ctx interface{}, mode interface{}, action interface{}, keys interface{}) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1631 | + | return &MockKeybindingsProvider_CheckConflicts_Call{Call: _e.mock.On("CheckConflicts", ctx, mode, action, keys)} | |||
| 1632 | + | } | |||
| 1633 | + | ||||
| 1634 | + | func (_c *MockKeybindingsProvider_CheckConflicts_Call) Run(run func(ctx context.Context, mode string, action string, keys []string)) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1635 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1636 | + | var arg0 context.Context | |||
| 1637 | + | if args[0] != nil { | |||
| 1638 | + | arg0 = args[0].(context.Context) | |||
| 1639 | + | } | |||
| 1640 | + | var arg1 string | |||
| 1641 | + | if args[1] != nil { | |||
| 1642 | + | arg1 = args[1].(string) | |||
| 1643 | + | } | |||
| 1644 | + | var arg2 string | |||
| 1645 | + | if args[2] != nil { | |||
| 1646 | + | arg2 = args[2].(string) | |||
| 1647 | + | } | |||
| 1648 | + | var arg3 []string | |||
| 1649 | + | if args[3] != nil { | |||
| 1650 | + | arg3 = args[3].([]string) | |||
| 1651 | + | } | |||
| 1652 | + | run( | |||
| 1653 | + | arg0, | |||
| 1654 | + | arg1, | |||
| 1655 | + | arg2, | |||
| 1656 | + | arg3, | |||
| 1657 | + | ) | |||
| 1658 | + | }) | |||
| 1659 | + | return _c | |||
| 1660 | + | } | |||
| 1661 | + | ||||
| 1662 | + | func (_c *MockKeybindingsProvider_CheckConflicts_Call) Return(keybindingConflicts []port.KeybindingConflict, err error) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1663 | + | _c.Call.Return(keybindingConflicts, err) | |||
| 1664 | + | return _c | |||
| 1665 | + | } | |||
| 1666 | + | ||||
| 1667 | + | func (_c *MockKeybindingsProvider_CheckConflicts_Call) RunAndReturn(run func(ctx context.Context, mode string, action string, keys []string) ([]port.KeybindingConflict, error)) *MockKeybindingsProvider_CheckConflicts_Call { | |||
| 1668 | + | _c.Call.Return(run) | |||
| 1669 | + | return _c | |||
| 1670 | + | } | |||
| 1671 | + | ||||
| 1672 | + | // GetDefaultKeybindings provides a mock function for the type MockKeybindingsProvider | |||
| 1673 | + | func (_mock *MockKeybindingsProvider) GetDefaultKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 1674 | + | ret := _mock.Called(ctx) | |||
| 1675 | + | ||||
| 1676 | + | if len(ret) == 0 { | |||
| 1677 | + | panic("no return value specified for GetDefaultKeybindings") | |||
| 1678 | + | } | |||
| 1679 | + | ||||
| 1680 | + | var r0 port.KeybindingsConfig | |||
| 1681 | + | var r1 error | |||
| 1682 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (port.KeybindingsConfig, error)); ok { | |||
| 1683 | + | return returnFunc(ctx) | |||
| 1684 | + | } | |||
| 1685 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.KeybindingsConfig); ok { | |||
| 1686 | + | r0 = returnFunc(ctx) | |||
| 1687 | + | } else { | |||
| 1688 | + | r0 = ret.Get(0).(port.KeybindingsConfig) | |||
| 1689 | + | } | |||
| 1690 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1691 | + | r1 = returnFunc(ctx) | |||
| 1692 | + | } else { | |||
| 1693 | + | r1 = ret.Error(1) | |||
| 1694 | + | } | |||
| 1695 | + | return r0, r1 | |||
| 1696 | + | } | |||
| 1697 | + | ||||
| 1698 | + | // MockKeybindingsProvider_GetDefaultKeybindings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDefaultKeybindings' | |||
| 1699 | + | type MockKeybindingsProvider_GetDefaultKeybindings_Call struct { | |||
| 1700 | + | *mock.Call | |||
| 1701 | + | } | |||
| 1702 | + | ||||
| 1703 | + | // GetDefaultKeybindings is a helper method to define mock.On call | |||
| 1704 | + | // - ctx context.Context | |||
| 1705 | + | func (_e *MockKeybindingsProvider_Expecter) GetDefaultKeybindings(ctx interface{}) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1706 | + | return &MockKeybindingsProvider_GetDefaultKeybindings_Call{Call: _e.mock.On("GetDefaultKeybindings", ctx)} | |||
| 1707 | + | } | |||
| 1708 | + | ||||
| 1709 | + | func (_c *MockKeybindingsProvider_GetDefaultKeybindings_Call) Run(run func(ctx context.Context)) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1710 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1711 | + | var arg0 context.Context | |||
| 1712 | + | if args[0] != nil { | |||
| 1713 | + | arg0 = args[0].(context.Context) | |||
| 1714 | + | } | |||
| 1715 | + | run( | |||
| 1716 | + | arg0, | |||
| 1717 | + | ) | |||
| 1718 | + | }) | |||
| 1719 | + | return _c | |||
| 1720 | + | } | |||
| 1721 | + | ||||
| 1722 | + | func (_c *MockKeybindingsProvider_GetDefaultKeybindings_Call) Return(keybindingsConfig port.KeybindingsConfig, err error) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1723 | + | _c.Call.Return(keybindingsConfig, err) | |||
| 1724 | + | return _c | |||
| 1725 | + | } | |||
| 1726 | + | ||||
| 1727 | + | func (_c *MockKeybindingsProvider_GetDefaultKeybindings_Call) RunAndReturn(run func(ctx context.Context) (port.KeybindingsConfig, error)) *MockKeybindingsProvider_GetDefaultKeybindings_Call { | |||
| 1728 | + | _c.Call.Return(run) | |||
| 1729 | + | return _c | |||
| 1730 | + | } | |||
| 1731 | + | ||||
| 1732 | + | // GetKeybindings provides a mock function for the type MockKeybindingsProvider | |||
| 1733 | + | func (_mock *MockKeybindingsProvider) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 1734 | + | ret := _mock.Called(ctx) | |||
| 1735 | + | ||||
| 1736 | + | if len(ret) == 0 { | |||
| 1737 | + | panic("no return value specified for GetKeybindings") | |||
| 1738 | + | } | |||
| 1739 | + | ||||
| 1740 | + | var r0 port.KeybindingsConfig | |||
| 1741 | + | var r1 error | |||
| 1742 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (port.KeybindingsConfig, error)); ok { | |||
| 1743 | + | return returnFunc(ctx) | |||
| 1744 | + | } | |||
| 1745 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.KeybindingsConfig); ok { | |||
| 1746 | + | r0 = returnFunc(ctx) | |||
| 1747 | + | } else { | |||
| 1748 | + | r0 = ret.Get(0).(port.KeybindingsConfig) | |||
| 1749 | + | } | |||
| 1750 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1751 | + | r1 = returnFunc(ctx) | |||
| 1752 | + | } else { | |||
| 1753 | + | r1 = ret.Error(1) | |||
| 1754 | + | } | |||
| 1755 | + | return r0, r1 | |||
| 1756 | + | } | |||
| 1757 | + | ||||
| 1758 | + | // MockKeybindingsProvider_GetKeybindings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeybindings' | |||
| 1759 | + | type MockKeybindingsProvider_GetKeybindings_Call struct { | |||
| 1760 | + | *mock.Call | |||
| 1761 | + | } | |||
| 1762 | + | ||||
| 1763 | + | // GetKeybindings is a helper method to define mock.On call | |||
| 1764 | + | // - ctx context.Context | |||
| 1765 | + | func (_e *MockKeybindingsProvider_Expecter) GetKeybindings(ctx interface{}) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1766 | + | return &MockKeybindingsProvider_GetKeybindings_Call{Call: _e.mock.On("GetKeybindings", ctx)} | |||
| 1767 | + | } | |||
| 1768 | + | ||||
| 1769 | + | func (_c *MockKeybindingsProvider_GetKeybindings_Call) Run(run func(ctx context.Context)) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1770 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1771 | + | var arg0 context.Context | |||
| 1772 | + | if args[0] != nil { | |||
| 1773 | + | arg0 = args[0].(context.Context) | |||
| 1774 | + | } | |||
| 1775 | + | run( | |||
| 1776 | + | arg0, | |||
| 1777 | + | ) | |||
| 1778 | + | }) | |||
| 1779 | + | return _c | |||
| 1780 | + | } | |||
| 1781 | + | ||||
| 1782 | + | func (_c *MockKeybindingsProvider_GetKeybindings_Call) Return(keybindingsConfig port.KeybindingsConfig, err error) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1783 | + | _c.Call.Return(keybindingsConfig, err) | |||
| 1784 | + | return _c | |||
| 1785 | + | } | |||
| 1786 | + | ||||
| 1787 | + | func (_c *MockKeybindingsProvider_GetKeybindings_Call) RunAndReturn(run func(ctx context.Context) (port.KeybindingsConfig, error)) *MockKeybindingsProvider_GetKeybindings_Call { | |||
| 1788 | + | _c.Call.Return(run) | |||
| 1789 | + | return _c | |||
| 1790 | + | } | |||
| 1791 | + | ||||
| 1792 | + | // NewMockKeybindingsSaver creates a new instance of MockKeybindingsSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1793 | + | // The first argument is typically a *testing.T value. | |||
| 1794 | + | func NewMockKeybindingsSaver(t interface { | |||
| 1795 | + | mock.TestingT | |||
| 1796 | + | Cleanup(func()) | |||
| 1797 | + | }) *MockKeybindingsSaver { | |||
| 1798 | + | mock := &MockKeybindingsSaver{} | |||
| 1799 | + | mock.Mock.Test(t) | |||
| 1800 | + | ||||
| 1801 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1802 | + | ||||
| 1803 | + | return mock | |||
| 1804 | + | } | |||
| 1805 | + | ||||
| 1806 | + | // MockKeybindingsSaver is an autogenerated mock type for the KeybindingsSaver type | |||
| 1807 | + | type MockKeybindingsSaver struct { | |||
| 1808 | + | mock.Mock | |||
| 1809 | + | } | |||
| 1810 | + | ||||
| 1811 | + | type MockKeybindingsSaver_Expecter struct { | |||
| 1812 | + | mock *mock.Mock | |||
| 1813 | + | } | |||
| 1814 | + | ||||
| 1815 | + | func (_m *MockKeybindingsSaver) EXPECT() *MockKeybindingsSaver_Expecter { | |||
| 1816 | + | return &MockKeybindingsSaver_Expecter{mock: &_m.Mock} | |||
| 1817 | + | } | |||
| 1818 | + | ||||
| 1819 | + | // ResetAllKeybindings provides a mock function for the type MockKeybindingsSaver | |||
| 1820 | + | func (_mock *MockKeybindingsSaver) ResetAllKeybindings(ctx context.Context) error { | |||
| 1821 | + | ret := _mock.Called(ctx) | |||
| 1822 | + | ||||
| 1823 | + | if len(ret) == 0 { | |||
| 1824 | + | panic("no return value specified for ResetAllKeybindings") | |||
| 1825 | + | } | |||
| 1826 | + | ||||
| 1827 | + | var r0 error | |||
| 1828 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |||
| 1829 | + | r0 = returnFunc(ctx) | |||
| 1830 | + | } else { | |||
| 1831 | + | r0 = ret.Error(0) | |||
| 1832 | + | } | |||
| 1833 | + | return r0 | |||
| 1834 | + | } | |||
| 1835 | + | ||||
| 1836 | + | // MockKeybindingsSaver_ResetAllKeybindings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetAllKeybindings' | |||
| 1837 | + | type MockKeybindingsSaver_ResetAllKeybindings_Call struct { | |||
| 1838 | + | *mock.Call | |||
| 1839 | + | } | |||
| 1840 | + | ||||
| 1841 | + | // ResetAllKeybindings is a helper method to define mock.On call | |||
| 1842 | + | // - ctx context.Context | |||
| 1843 | + | func (_e *MockKeybindingsSaver_Expecter) ResetAllKeybindings(ctx interface{}) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1844 | + | return &MockKeybindingsSaver_ResetAllKeybindings_Call{Call: _e.mock.On("ResetAllKeybindings", ctx)} | |||
| 1845 | + | } | |||
| 1846 | + | ||||
| 1847 | + | func (_c *MockKeybindingsSaver_ResetAllKeybindings_Call) Run(run func(ctx context.Context)) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1848 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1849 | + | var arg0 context.Context | |||
| 1850 | + | if args[0] != nil { | |||
| 1851 | + | arg0 = args[0].(context.Context) | |||
| 1852 | + | } | |||
| 1853 | + | run( | |||
| 1854 | + | arg0, | |||
| 1855 | + | ) | |||
| 1856 | + | }) | |||
| 1857 | + | return _c | |||
| 1858 | + | } | |||
| 1859 | + | ||||
| 1860 | + | func (_c *MockKeybindingsSaver_ResetAllKeybindings_Call) Return(err error) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1861 | + | _c.Call.Return(err) | |||
| 1862 | + | return _c | |||
| 1863 | + | } | |||
| 1864 | + | ||||
| 1865 | + | func (_c *MockKeybindingsSaver_ResetAllKeybindings_Call) RunAndReturn(run func(ctx context.Context) error) *MockKeybindingsSaver_ResetAllKeybindings_Call { | |||
| 1866 | + | _c.Call.Return(run) | |||
| 1867 | + | return _c | |||
| 1868 | + | } | |||
| 1869 | + | ||||
| 1870 | + | // ResetKeybinding provides a mock function for the type MockKeybindingsSaver | |||
| 1871 | + | func (_mock *MockKeybindingsSaver) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | |||
| 1872 | + | ret := _mock.Called(ctx, req) | |||
| 1873 | + | ||||
| 1874 | + | if len(ret) == 0 { | |||
| 1875 | + | panic("no return value specified for ResetKeybinding") | |||
| 1876 | + | } | |||
| 1877 | + | ||||
| 1878 | + | var r0 error | |||
| 1879 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ResetKeybindingRequest) error); ok { | |||
| 1880 | + | r0 = returnFunc(ctx, req) | |||
| 1881 | + | } else { | |||
| 1882 | + | r0 = ret.Error(0) | |||
| 1883 | + | } | |||
| 1884 | + | return r0 | |||
| 1885 | + | } | |||
| 1886 | + | ||||
| 1887 | + | // MockKeybindingsSaver_ResetKeybinding_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetKeybinding' | |||
| 1888 | + | type MockKeybindingsSaver_ResetKeybinding_Call struct { | |||
| 1889 | + | *mock.Call | |||
| 1890 | + | } | |||
| 1891 | + | ||||
| 1892 | + | // ResetKeybinding is a helper method to define mock.On call | |||
| 1893 | + | // - ctx context.Context | |||
| 1894 | + | // - req port.ResetKeybindingRequest | |||
| 1895 | + | func (_e *MockKeybindingsSaver_Expecter) ResetKeybinding(ctx interface{}, req interface{}) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1896 | + | return &MockKeybindingsSaver_ResetKeybinding_Call{Call: _e.mock.On("ResetKeybinding", ctx, req)} | |||
| 1897 | + | } | |||
| 1898 | + | ||||
| 1899 | + | func (_c *MockKeybindingsSaver_ResetKeybinding_Call) Run(run func(ctx context.Context, req port.ResetKeybindingRequest)) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1900 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1901 | + | var arg0 context.Context | |||
| 1902 | + | if args[0] != nil { | |||
| 1903 | + | arg0 = args[0].(context.Context) | |||
| 1904 | + | } | |||
| 1905 | + | var arg1 port.ResetKeybindingRequest | |||
| 1906 | + | if args[1] != nil { | |||
| 1907 | + | arg1 = args[1].(port.ResetKeybindingRequest) | |||
| 1908 | + | } | |||
| 1909 | + | run( | |||
| 1910 | + | arg0, | |||
| 1911 | + | arg1, | |||
| 1912 | + | ) | |||
| 1913 | + | }) | |||
| 1914 | + | return _c | |||
| 1915 | + | } | |||
| 1916 | + | ||||
| 1917 | + | func (_c *MockKeybindingsSaver_ResetKeybinding_Call) Return(err error) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1918 | + | _c.Call.Return(err) | |||
| 1919 | + | return _c | |||
| 1920 | + | } | |||
| 1921 | + | ||||
| 1922 | + | func (_c *MockKeybindingsSaver_ResetKeybinding_Call) RunAndReturn(run func(ctx context.Context, req port.ResetKeybindingRequest) error) *MockKeybindingsSaver_ResetKeybinding_Call { | |||
| 1923 | + | _c.Call.Return(run) | |||
| 1924 | + | return _c | |||
| 1925 | + | } | |||
| 1926 | + | ||||
| 1927 | + | // SetKeybinding provides a mock function for the type MockKeybindingsSaver | |||
| 1928 | + | func (_mock *MockKeybindingsSaver) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) error { | |||
| 1929 | + | ret := _mock.Called(ctx, req) | |||
| 1930 | + | ||||
| 1931 | + | if len(ret) == 0 { | |||
| 1932 | + | panic("no return value specified for SetKeybinding") | |||
| 1933 | + | } | |||
| 1934 | + | ||||
| 1935 | + | var r0 error | |||
| 1936 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.SetKeybindingRequest) error); ok { | |||
| 1937 | + | r0 = returnFunc(ctx, req) | |||
| 1938 | + | } else { | |||
| 1939 | + | r0 = ret.Error(0) | |||
| 1940 | + | } | |||
| 1941 | + | return r0 | |||
| 1942 | + | } | |||
| 1943 | + | ||||
| 1944 | + | // MockKeybindingsSaver_SetKeybinding_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetKeybinding' | |||
| 1945 | + | type MockKeybindingsSaver_SetKeybinding_Call struct { | |||
| 1946 | + | *mock.Call | |||
| 1947 | + | } | |||
| 1948 | + | ||||
| 1949 | + | // SetKeybinding is a helper method to define mock.On call | |||
| 1950 | + | // - ctx context.Context | |||
| 1951 | + | // - req port.SetKeybindingRequest | |||
| 1952 | + | func (_e *MockKeybindingsSaver_Expecter) SetKeybinding(ctx interface{}, req interface{}) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1953 | + | return &MockKeybindingsSaver_SetKeybinding_Call{Call: _e.mock.On("SetKeybinding", ctx, req)} | |||
| 1954 | + | } | |||
| 1955 | + | ||||
| 1956 | + | func (_c *MockKeybindingsSaver_SetKeybinding_Call) Run(run func(ctx context.Context, req port.SetKeybindingRequest)) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1957 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1958 | + | var arg0 context.Context | |||
| 1959 | + | if args[0] != nil { | |||
| 1960 | + | arg0 = args[0].(context.Context) | |||
| 1961 | + | } | |||
| 1962 | + | var arg1 port.SetKeybindingRequest | |||
| 1963 | + | if args[1] != nil { | |||
| 1964 | + | arg1 = args[1].(port.SetKeybindingRequest) | |||
| 1965 | + | } | |||
| 1966 | + | run( | |||
| 1967 | + | arg0, | |||
| 1968 | + | arg1, | |||
| 1969 | + | ) | |||
| 1970 | + | }) | |||
| 1971 | + | return _c | |||
| 1972 | + | } | |||
| 1973 | + | ||||
| 1974 | + | func (_c *MockKeybindingsSaver_SetKeybinding_Call) Return(err error) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1975 | + | _c.Call.Return(err) | |||
| 1976 | + | return _c | |||
| 1977 | + | } | |||
| 1978 | + | ||||
| 1979 | + | func (_c *MockKeybindingsSaver_SetKeybinding_Call) RunAndReturn(run func(ctx context.Context, req port.SetKeybindingRequest) error) *MockKeybindingsSaver_SetKeybinding_Call { | |||
| 1980 | + | _c.Call.Return(run) | |||
| 1981 | + | return _c | |||
| 1982 | + | } | |||
| 1983 | + | ||||
| 1498 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1984 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1499 | // The first argument is typically a *testing.T value. | 1985 | // The first argument is typically a *testing.T value. | ||
| 1500 | func NewMockSessionLogger(t interface { | 1986 | func NewMockSessionLogger(t interface { | ||
A internal/application/usecase/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 | // Looks like a URL (contains . and no spaces) | 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 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | 88 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | ||
| 86 | return "https://" + input | 89 | return "https://" + input | ||
| 87 | } | 90 | } | ||
| @@ -112,6 +115,9 @@ func LooksLikeURL(input string) bool { | |||||
| 112 | } | 115 | } | ||
| 113 | 116 | ||||
| 114 | // Contains a dot and no spaces = likely a URL | 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 | return strings.Contains(input, ".") && !strings.Contains(input, " ") | 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 | input: "hello", | 62 | input: "hello", | ||
| 63 | want: "hello", | 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 | for _, tt := range tests { | 97 | for _, tt := range tests { | ||
| @@ -257,6 +287,36 @@ func TestLooksLikeURL(t *testing.T) { | |||||
| 257 | input: "hello", | 287 | input: "hello", | ||
| 258 | want: false, | 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 | for _, tt := range tests { | 322 | for _, tt := range tests { | ||
M internal/infrastructure/config/defaults.go +53 -52
| @@ -161,40 +161,40 @@ func DefaultConfig() *Config { | |||||
| 161 | PaneMode: PaneModeConfig{ | 161 | PaneMode: PaneModeConfig{ | ||
| 162 | ActivationShortcut: defaultPaneActivationShortcut, | 162 | ActivationShortcut: defaultPaneActivationShortcut, | ||
| 163 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | 163 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | ||
| 164 | - | Actions: map[string][]string{ | 164 | + | Actions: map[string]ActionBinding{ |
| 165 | - | "split-right": {"arrowright", "r"}, | 165 | + | "split-right": {Keys: []string{"arrowright", "r"}, Desc: "Split pane to the right"}, |
| 166 | - | "split-left": {"arrowleft", "l"}, | 166 | + | "split-left": {Keys: []string{"arrowleft", "l"}, Desc: "Split pane to the left"}, |
| 167 | - | "split-up": {"arrowup", "u"}, | 167 | + | "split-up": {Keys: []string{"arrowup", "u"}, Desc: "Split pane upward"}, |
| 168 | - | "split-down": {"arrowdown", "d"}, | 168 | + | "split-down": {Keys: []string{"arrowdown", "d"}, Desc: "Split pane downward"}, |
| 169 | - | "stack-pane": {"s"}, | 169 | + | "stack-pane": {Keys: []string{"s"}, Desc: "Stack pane with sibling"}, |
| 170 | - | "close-pane": {"x"}, | 170 | + | "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"}, |
| 171 | - | "move-pane-to-tab": {"m"}, | 171 | + | "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"}, |
| 172 | - | "move-pane-to-next-tab": {"M", "shift+m"}, | 172 | + | "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}, Desc: "Move pane to next tab"}, |
| 173 | 173 | ||||
| 174 | - | "consume-or-expel-left": {"bracketleft", "["}, | 174 | + | "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"}, |
| 175 | - | "consume-or-expel-right": {"bracketright", "]"}, | 175 | + | "consume-or-expel-right": {Keys: []string{"]"}, Desc: "Consume/expel pane right"}, |
| 176 | - | "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"}, | 176 | + | "consume-or-expel-up": {Keys: []string{"{"}, Desc: "Consume/expel pane up"}, |
| 177 | - | "consume-or-expel-down": {"shift+bracketright", "braceright", "}"}, | 177 | + | "consume-or-expel-down": {Keys: []string{"}"}, Desc: "Consume/expel pane down"}, |
| 178 | 178 | ||||
| 179 | - | "focus-right": {"shift+arrowright", "shift+l"}, | 179 | + | "focus-right": {Keys: []string{"shift+arrowright", "shift+l"}, Desc: "Focus pane to the right"}, |
| 180 | - | "focus-left": {"shift+arrowleft", "shift+h"}, | 180 | + | "focus-left": {Keys: []string{"shift+arrowleft", "shift+h"}, Desc: "Focus pane to the left"}, |
| 181 | - | "focus-up": {"shift+arrowup", "shift+k"}, | 181 | + | "focus-up": {Keys: []string{"shift+arrowup", "shift+k"}, Desc: "Focus pane above"}, |
| 182 | - | "focus-down": {"shift+arrowdown", "shift+j"}, | 182 | + | "focus-down": {Keys: []string{"shift+arrowdown", "shift+j"}, Desc: "Focus pane below"}, |
| 183 | - | "confirm": {"enter"}, | 183 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 184 | - | "cancel": {"escape"}, | 184 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 185 | }, | 185 | }, | ||
| 186 | }, | 186 | }, | ||
| 187 | TabMode: TabModeConfig{ | 187 | TabMode: TabModeConfig{ | ||
| 188 | ActivationShortcut: defaultTabActivationShortcut, | 188 | ActivationShortcut: defaultTabActivationShortcut, | ||
| 189 | TimeoutMilliseconds: defaultTabTimeoutMilliseconds, | 189 | TimeoutMilliseconds: defaultTabTimeoutMilliseconds, | ||
| 190 | - | Actions: map[string][]string{ | 190 | + | Actions: map[string]ActionBinding{ |
| 191 | - | "new-tab": {"n", "c"}, | 191 | + | "new-tab": {Keys: []string{"n", "c"}, Desc: "Create new tab"}, |
| 192 | - | "close-tab": {"x"}, | 192 | + | "close-tab": {Keys: []string{"x"}, Desc: "Close current tab"}, |
| 193 | - | "next-tab": {"l", "tab"}, | 193 | + | "next-tab": {Keys: []string{"l", "tab"}, Desc: "Switch to next tab"}, |
| 194 | - | "previous-tab": {"h", "shift+tab"}, | 194 | + | "previous-tab": {Keys: []string{"h", "shift+tab"}, Desc: "Switch to previous tab"}, |
| 195 | - | "rename-tab": {"r"}, | 195 | + | "rename-tab": {Keys: []string{"r"}, Desc: "Rename current tab"}, |
| 196 | - | "confirm": {"enter"}, | 196 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 197 | - | "cancel": {"escape"}, | 197 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 198 | }, | 198 | }, | ||
| 199 | }, | 199 | }, | ||
| 200 | ResizeMode: ResizeModeConfig{ | 200 | ResizeMode: ResizeModeConfig{ | ||
| @@ -202,30 +202,31 @@ func DefaultConfig() *Config { | |||||
| 202 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | 202 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | ||
| 203 | StepPercent: defaultResizeStepPercent, | 203 | StepPercent: defaultResizeStepPercent, | ||
| 204 | MinPanePercent: defaultResizeMinPanePercent, | 204 | MinPanePercent: defaultResizeMinPanePercent, | ||
| 205 | - | Actions: map[string][]string{ | 205 | + | Actions: map[string]ActionBinding{ |
| 206 | - | "resize-increase-left": {"h", "arrowleft"}, | 206 | + | "resize-increase-left": {Keys: []string{"h", "arrowleft"}, Desc: "Increase size leftward"}, |
| 207 | - | "resize-increase-down": {"j", "arrowdown"}, | 207 | + | "resize-increase-down": {Keys: []string{"j", "arrowdown"}, Desc: "Increase size downward"}, |
| 208 | - | "resize-increase-up": {"k", "arrowup"}, | 208 | + | "resize-increase-up": {Keys: []string{"k", "arrowup"}, Desc: "Increase size upward"}, |
| 209 | - | "resize-increase-right": {"l", "arrowright"}, | 209 | + | "resize-increase-right": {Keys: []string{"l", "arrowright"}, Desc: "Increase size rightward"}, |
| 210 | - | "resize-decrease-left": {"H"}, | 210 | + | "resize-decrease-left": {Keys: []string{"H"}, Desc: "Decrease size leftward"}, |
| 211 | - | "resize-decrease-down": {"J"}, | 211 | + | "resize-decrease-down": {Keys: []string{"J"}, Desc: "Decrease size downward"}, |
| 212 | - | "resize-decrease-up": {"K"}, | 212 | + | "resize-decrease-up": {Keys: []string{"K"}, Desc: "Decrease size upward"}, |
| 213 | - | "resize-decrease-right": {"L"}, | 213 | + | "resize-decrease-right": {Keys: []string{"L"}, Desc: "Decrease size rightward"}, |
| 214 | - | "resize-increase": {"+", "="}, | 214 | + | "resize-increase": {Keys: []string{"+", "="}, Desc: "Increase pane size"}, |
| 215 | - | "resize-decrease": {"-"}, | 215 | + | "resize-decrease": {Keys: []string{"-"}, Desc: "Decrease pane size"}, |
| 216 | - | "confirm": {"enter"}, | 216 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 217 | - | "cancel": {"escape"}, | 217 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 218 | }, | 218 | }, | ||
| 219 | }, | 219 | }, | ||
| 220 | Shortcuts: GlobalShortcutsConfig{ | 220 | Shortcuts: GlobalShortcutsConfig{ | ||
| 221 | - | ClosePane: "ctrl+w", | 221 | + | Actions: map[string]ActionBinding{ |
| 222 | - | NextTab: "ctrl+tab", | 222 | + | "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, |
| 223 | - | PreviousTab: "ctrl+shift+tab", | 223 | + | "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, |
| 224 | - | 224 | + | "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, | |
| 225 | - | ConsumeOrExpelLeft: "alt+bracketleft", | 225 | + | "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, |
| 226 | - | ConsumeOrExpelRight: "alt+bracketright", | 226 | + | "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, |
| 227 | - | ConsumeOrExpelUp: "alt+shift+bracketleft", | 227 | + | "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, |
| 228 | - | ConsumeOrExpelDown: "alt+shift+bracketright", | 228 | + | "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, |
| 229 | + | }, | |||
| 229 | }, | 230 | }, | ||
| 230 | TabBarPosition: defaultTabBarPosition, | 231 | TabBarPosition: defaultTabBarPosition, | ||
| 231 | HideTabBarWhenSingleTab: true, | 232 | HideTabBarWhenSingleTab: true, | ||
| @@ -269,10 +270,10 @@ func DefaultConfig() *Config { | |||||
| 269 | SessionMode: SessionModeConfig{ | 270 | SessionMode: SessionModeConfig{ | ||
| 270 | ActivationShortcut: defaultSessionActivationShortcut, | 271 | ActivationShortcut: defaultSessionActivationShortcut, | ||
| 271 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | 272 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | ||
| 272 | - | Actions: map[string][]string{ | 273 | + | Actions: map[string]ActionBinding{ |
| 273 | - | "session-manager": {"s", "w"}, | 274 | + | "session-manager": {Keys: []string{"s", "w"}, Desc: "Open session manager"}, |
| 274 | - | "confirm": {"enter"}, | 275 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, |
| 275 | - | "cancel": {"escape"}, | 276 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, |
| 276 | }, | 277 | }, | ||
| 277 | }, | 278 | }, | ||
| 278 | }, | 279 | }, | ||
A internal/infrastructure/config/keybindings_gateway.go +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 | // Manager handles configuration loading, watching, and reloading. | 18 | // Manager handles configuration loading, watching, and reloading. | ||
| 19 | type Manager struct { | 19 | type Manager struct { | ||
| 20 | - | config *Config | 20 | + | config *Config |
| 21 | - | viper *viper.Viper | 21 | + | viper *viper.Viper |
| 22 | - | mu sync.RWMutex | 22 | + | mu sync.RWMutex |
| 23 | - | callbacks []func(*Config) | 23 | + | callbacks []func(*Config) |
| 24 | - | watching bool | 24 | + | watching bool |
| 25 | + | skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config | |||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | // NewManager creates a new configuration manager. | 28 | // NewManager creates a new configuration manager. | ||
| @@ -130,9 +131,26 @@ func (m *Manager) readConfigFile() error { | |||||
| 130 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | 131 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | ||
| 131 | } | 132 | } | ||
| 132 | } | 133 | } | ||
| 134 | + | ||||
| 135 | + | // Transform legacy action bindings before unmarshaling | |||
| 136 | + | m.transformLegacyActionBindings() | |||
| 137 | + | ||||
| 133 | return nil | 138 | return nil | ||
| 134 | } | 139 | } | ||
| 135 | 140 | ||||
| 141 | + | // transformLegacyActionBindings converts old-format action bindings to new format. | |||
| 142 | + | // This is called after reading config but before unmarshaling. | |||
| 143 | + | func (m *Manager) transformLegacyActionBindings() { | |||
| 144 | + | rawConfig := m.viper.AllSettings() | |||
| 145 | + | transformer := NewLegacyConfigTransformer() | |||
| 146 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 147 | + | ||||
| 148 | + | // Apply transformed config back to viper | |||
| 149 | + | for key, value := range rawConfig { | |||
| 150 | + | m.viper.Set(key, value) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 136 | func (m *Manager) unmarshalConfig() (*Config, error) { | 154 | func (m *Manager) unmarshalConfig() (*Config, error) { | ||
| 137 | config := &Config{} | 155 | config := &Config{} | ||
| 138 | if err := m.viper.Unmarshal(config); err != nil { | 156 | if err := m.viper.Unmarshal(config); err != nil { | ||
| @@ -242,7 +260,7 @@ func (m *Manager) Get() *Config { | |||||
| 242 | return &configCopy | 260 | return &configCopy | ||
| 243 | } | 261 | } | ||
| 244 | 262 | ||||
| 245 | - | // Save saves the provided configuration to disk and updates Viper. | 263 | + | // Save saves the provided configuration to disk with ordered sections. |
| 246 | func (m *Manager) Save(cfg *Config) error { | 264 | func (m *Manager) Save(cfg *Config) error { | ||
| 247 | m.mu.Lock() | 265 | m.mu.Lock() | ||
| 248 | defer m.mu.Unlock() | 266 | defer m.mu.Unlock() | ||
| @@ -256,31 +274,32 @@ func (m *Manager) Save(cfg *Config) error { | |||||
| 256 | return fmt.Errorf("configuration validation failed: %w", err) | 274 | return fmt.Errorf("configuration validation failed: %w", err) | ||
| 257 | } | 275 | } | ||
| 258 | 276 | ||||
| 259 | - | // Update Viper with the new values. | 277 | + | // Write config with ordered sections (bypasses Viper's random map ordering) |
| 260 | - | // Since we can't easily update Viper from a struct automatically while preserving | 278 | + | configFile := m.viper.ConfigFileUsed() |
| 261 | - | // all settings, we update the main keys we care about. | 279 | + | if configFile == "" { |
| 262 | - | m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont) | 280 | + | var err error |
| 263 | - | m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont) | 281 | + | configFile, err = GetConfigFile() |
| 264 | - | m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont) | 282 | + | if err != nil { |
| 265 | - | m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize) | 283 | + | return fmt.Errorf("failed to determine config file path: %w", err) |
| 266 | - | m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme) | 284 | + | } |
| 267 | - | m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette) | 285 | + | } |
| 268 | - | m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette) | 286 | + | |
| 269 | - | m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom) | 287 | + | // Set flag BEFORE writing to prevent fsnotify-triggered reload from |
| 270 | - | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | 288 | + | // overwriting our in-memory config with stale viper cache |
| 271 | - | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | 289 | + | m.skipNextReload = true |
| 272 | - | 290 | + | ||
| 273 | - | // Performance profile (requires browser restart to take effect) | 291 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { |
| 274 | - | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | 292 | + | m.skipNextReload = false // Reset on error |
| 275 | - | ||||
| 276 | - | if err := m.viper.WriteConfig(); err != nil { | |||
| 277 | return fmt.Errorf("failed to write config: %w", err) | 293 | return fmt.Errorf("failed to write config: %w", err) | ||
| 278 | } | 294 | } | ||
| 279 | 295 | ||||
| 280 | - | // We don't update m.config here manually because viper.OnConfigChange | 296 | + | // Update in-memory config immediately (don't wait for file watcher) |
| 281 | - | // will trigger reload() if Watch() is active. | 297 | + | // This ensures subsequent Get() calls return the new values |
| 282 | - | // If Watch() is not active, we should call reload() manually. | 298 | + | m.config = cfg |
| 299 | + | ||||
| 300 | + | // Also reload from disk to sync Viper's internal state (if not watching) | |||
| 283 | if !m.watching { | 301 | if !m.watching { | ||
| 302 | + | m.skipNextReload = false // Clear flag since we're doing manual reload | |||
| 284 | if err := m.reload(); err != nil { | 303 | if err := m.reload(); err != nil { | ||
| 285 | return err | 304 | return err | ||
| 286 | } | 305 | } | ||
| @@ -294,7 +313,7 @@ func (m *Manager) GetConfigFile() string { | |||||
| 294 | return m.viper.ConfigFileUsed() | 313 | return m.viper.ConfigFileUsed() | ||
| 295 | } | 314 | } | ||
| 296 | 315 | ||||
| 297 | - | // createDefaultConfig creates a default configuration file. | 316 | + | // createDefaultConfig creates a default configuration file with ordered sections. |
| 298 | func (m *Manager) createDefaultConfig() error { | 317 | func (m *Manager) createDefaultConfig() error { | ||
| 299 | configFile, err := GetConfigFile() | 318 | configFile, err := GetConfigFile() | ||
| 300 | if err != nil { | 319 | if err != nil { | ||
| @@ -306,12 +325,14 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 306 | return err | 325 | return err | ||
| 307 | } | 326 | } | ||
| 308 | 327 | ||||
| 309 | - | // Detect available fonts and override defaults before writing config. | 328 | + | // Start with default config |
| 310 | - | m.detectAndSetFonts() | 329 | + | cfg := DefaultConfig() |
| 330 | + | ||||
| 331 | + | // Detect available fonts and override defaults | |||
| 332 | + | m.detectAndSetFontsOnConfig(cfg) | |||
| 311 | 333 | ||||
| 312 | - | // Ensure TOML format and write config | 334 | + | // Write config with ordered sections |
| 313 | - | m.viper.SetConfigType("toml") | 335 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { |
| 314 | - | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |||
| 315 | return fmt.Errorf("failed to write config file: %w", err) | 336 | return fmt.Errorf("failed to write config file: %w", err) | ||
| 316 | } | 337 | } | ||
| 317 | 338 | ||||
| @@ -320,9 +341,9 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 320 | return nil | 341 | return nil | ||
| 321 | } | 342 | } | ||
| 322 | 343 | ||||
| 323 | - | // detectAndSetFonts detects available system fonts and sets the best available | 344 | + | // detectAndSetFontsOnConfig detects available system fonts and sets the best available |
| 324 | - | // fonts from the fallback chains. This runs only during first-run config creation. | 345 | + | // fonts from the fallback chains directly on the Config struct. |
| 325 | - | func (m *Manager) detectAndSetFonts() { | 346 | + | func (*Manager) detectAndSetFontsOnConfig(cfg *Config) { |
| 326 | // Create context with logger for debugging first-run font detection. | 347 | // Create context with logger for debugging first-run font detection. | ||
| 327 | logger := logging.NewFromEnv() | 348 | logger := logging.NewFromEnv() | ||
| 328 | ctx := logging.WithContext(context.Background(), logger) | 349 | ctx := logging.WithContext(context.Background(), logger) | ||
| @@ -334,13 +355,9 @@ func (m *Manager) detectAndSetFonts() { | |||||
| 334 | return | 355 | return | ||
| 335 | } | 356 | } | ||
| 336 | 357 | ||||
| 337 | - | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | 358 | + | cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) |
| 338 | - | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | 359 | + | cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) |
| 339 | - | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | 360 | + | cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) |
| 340 | - | ||||
| 341 | - | m.viper.Set("appearance.sans_font", sansFont) | |||
| 342 | - | m.viper.Set("appearance.serif_font", serifFont) | |||
| 343 | - | m.viper.Set("appearance.monospace_font", monoFont) | |||
| 344 | } | 361 | } | ||
| 345 | 362 | ||||
| 346 | // setDefaults sets default configuration values in Viper. | 363 | // setDefaults sets default configuration values in Viper. | ||
| @@ -443,13 +460,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 443 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | 460 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | ||
| 444 | m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent) | 461 | m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent) | ||
| 445 | m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions) | 462 | m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions) | ||
| 446 | - | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | 463 | + | m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions) |
| 447 | - | m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab) | |||
| 448 | - | m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab) | |||
| 449 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft) | |||
| 450 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight) | |||
| 451 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp) | |||
| 452 | - | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | |||
| 453 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | 464 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | ||
| 454 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | 465 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | ||
| 455 | m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove) | 466 | m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove) | ||
M internal/infrastructure/config/migrate.go +16 -2
| @@ -371,6 +371,10 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | 371 | return nil, fmt.Errorf("failed to read config file: %w", err) | ||
| 372 | } | 372 | } | ||
| 373 | 373 | ||||
| 374 | + | // Transform legacy action bindings (slice -> ActionBinding struct) | |||
| 375 | + | transformer := NewLegacyConfigTransformer() | |||
| 376 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 377 | + | ||||
| 374 | // Build sets of keys to remove and renames to apply | 378 | // Build sets of keys to remove and renames to apply | ||
| 375 | keysToRemove := make(map[string]bool) | 379 | keysToRemove := make(map[string]bool) | ||
| 376 | renames := make(map[string]string) // oldKey -> newKey | 380 | renames := make(map[string]string) // oldKey -> newKey | ||
| @@ -416,8 +420,14 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 416 | return nil, fmt.Errorf("failed to merge config: %w", err) | 420 | return nil, fmt.Errorf("failed to merge config: %w", err) | ||
| 417 | } | 421 | } | ||
| 418 | 422 | ||||
| 419 | - | // Write the merged config back | 423 | + | // Unmarshal to Config struct for ordered writing |
| 420 | - | if err := userViper.WriteConfig(); err != nil { | 424 | + | var cfg Config |
| 425 | + | if err := userViper.Unmarshal(&cfg); err != nil { | |||
| 426 | + | return nil, fmt.Errorf("failed to unmarshal merged config: %w", err) | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // Write with ordered sections | |||
| 430 | + | if err := WriteConfigOrdered(&cfg, configFile); err != nil { | |||
| 421 | return nil, fmt.Errorf("failed to write config file: %w", err) | 431 | return nil, fmt.Errorf("failed to write config file: %w", err) | ||
| 422 | } | 432 | } | ||
| 423 | 433 | ||||
| @@ -635,6 +645,10 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an | |||||
| 635 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | 645 | return nil, fmt.Errorf("failed to parse TOML: %w", err) | ||
| 636 | } | 646 | } | ||
| 637 | 647 | ||||
| 648 | + | // Transform legacy action bindings before flattening | |||
| 649 | + | transformer := NewLegacyConfigTransformer() | |||
| 650 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 651 | + | ||||
| 638 | // Flatten the map to dot-notation keys with values | 652 | // Flatten the map to dot-notation keys with values | ||
| 639 | result := make(map[string]any) | 653 | result := make(map[string]any) | ||
| 640 | m.flattenMapWithValues(rawConfig, "", result) | 654 | m.flattenMapWithValues(rawConfig, "", result) | ||
M internal/infrastructure/config/schema.go +39 -29
| @@ -410,19 +410,25 @@ type WorkspaceConfig struct { | |||||
| 410 | Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"` | 410 | Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"` | ||
| 411 | } | 411 | } | ||
| 412 | 412 | ||||
| 413 | + | // ActionBinding defines a keybinding with optional description. | |||
| 414 | + | type ActionBinding struct { | |||
| 415 | + | Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"` | |||
| 416 | + | Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"` | |||
| 417 | + | } | |||
| 418 | + | ||||
| 413 | // PaneModeConfig defines modal behavior for pane management. | 419 | // PaneModeConfig defines modal behavior for pane management. | ||
| 414 | type PaneModeConfig struct { | 420 | type PaneModeConfig struct { | ||
| 415 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 421 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 416 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 422 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 417 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 423 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 418 | } | 424 | } | ||
| 419 | 425 | ||||
| 420 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 426 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 421 | // This is built from the action→keys structure in the config. | 427 | // This is built from the action→keys structure in the config. | ||
| 422 | func (p *PaneModeConfig) GetKeyBindings() map[string]string { | 428 | func (p *PaneModeConfig) GetKeyBindings() map[string]string { | ||
| 423 | keyToAction := make(map[string]string) | 429 | keyToAction := make(map[string]string) | ||
| 424 | - | for action, keys := range p.Actions { | 430 | + | for action, binding := range p.Actions { |
| 425 | - | for _, key := range keys { | 431 | + | for _, key := range binding.Keys { |
| 426 | keyToAction[key] = action | 432 | keyToAction[key] = action | ||
| 427 | } | 433 | } | ||
| 428 | } | 434 | } | ||
| @@ -431,17 +437,17 @@ func (p *PaneModeConfig) GetKeyBindings() map[string]string { | |||||
| 431 | 437 | ||||
| 432 | // TabModeConfig defines modal behavior for tab management (Zellij-style). | 438 | // TabModeConfig defines modal behavior for tab management (Zellij-style). | ||
| 433 | type TabModeConfig struct { | 439 | type TabModeConfig struct { | ||
| 434 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 440 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 435 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 441 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 436 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 442 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 437 | } | 443 | } | ||
| 438 | 444 | ||||
| 439 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 445 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 440 | // This is built from the action→keys structure in the config. | 446 | // This is built from the action→keys structure in the config. | ||
| 441 | func (t *TabModeConfig) GetKeyBindings() map[string]string { | 447 | func (t *TabModeConfig) GetKeyBindings() map[string]string { | ||
| 442 | keyToAction := make(map[string]string) | 448 | keyToAction := make(map[string]string) | ||
| 443 | - | for action, keys := range t.Actions { | 449 | + | for action, binding := range t.Actions { |
| 444 | - | for _, key := range keys { | 450 | + | for _, key := range binding.Keys { |
| 445 | keyToAction[key] = action | 451 | keyToAction[key] = action | ||
| 446 | } | 452 | } | ||
| 447 | } | 453 | } | ||
| @@ -450,19 +456,19 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||||
| 450 | 456 | ||||
| 451 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | 457 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | ||
| 452 | type ResizeModeConfig struct { | 458 | type ResizeModeConfig struct { | ||
| 453 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 459 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 454 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 460 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 455 | - | StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"` | 461 | + | StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"` |
| 456 | - | MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line | 462 | + | MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line |
| 457 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 463 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 458 | } | 464 | } | ||
| 459 | 465 | ||||
| 460 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 466 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 461 | // This is built from the action→keys structure in the config. | 467 | // This is built from the action→keys structure in the config. | ||
| 462 | func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | 468 | func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | ||
| 463 | keyToAction := make(map[string]string) | 469 | keyToAction := make(map[string]string) | ||
| 464 | - | for action, keys := range r.Actions { | 470 | + | for action, binding := range r.Actions { |
| 465 | - | for _, key := range keys { | 471 | + | for _, key := range binding.Keys { |
| 466 | keyToAction[key] = action | 472 | keyToAction[key] = action | ||
| 467 | } | 473 | } | ||
| 468 | } | 474 | } | ||
| @@ -471,14 +477,18 @@ func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | |||||
| 471 | 477 | ||||
| 472 | // GlobalShortcutsConfig defines global shortcuts (always active, not modal). | 478 | // GlobalShortcutsConfig defines global shortcuts (always active, not modal). | ||
| 473 | type GlobalShortcutsConfig struct { | 479 | type GlobalShortcutsConfig struct { | ||
| 474 | - | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | 480 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 475 | - | NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"` | 481 | + | } |
| 476 | - | PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"` | |||
| 477 | 482 | ||||
| 478 | - | ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line | 483 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. |
| 479 | - | ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line | 484 | + | func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string { |
| 480 | - | ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line | 485 | + | keyToAction := make(map[string]string) |
| 481 | - | ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line | 486 | + | for action, binding := range g.Actions { |
| 487 | + | for _, key := range binding.Keys { | |||
| 488 | + | keyToAction[key] = action | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | return keyToAction | |||
| 482 | } | 492 | } | ||
| 483 | 493 | ||||
| 484 | // PopupBehavior defines how popup windows should be opened | 494 | // PopupBehavior defines how popup windows should be opened | ||
| @@ -574,17 +584,17 @@ type SessionConfig struct { | |||||
| 574 | 584 | ||||
| 575 | // SessionModeConfig defines modal behavior for session management. | 585 | // SessionModeConfig defines modal behavior for session management. | ||
| 576 | type SessionModeConfig struct { | 586 | type SessionModeConfig struct { | ||
| 577 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 587 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 578 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 588 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` |
| 579 | - | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 589 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` |
| 580 | } | 590 | } | ||
| 581 | 591 | ||||
| 582 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | 592 | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | ||
| 583 | // This is built from the action→keys structure in the config. | 593 | // This is built from the action→keys structure in the config. | ||
| 584 | func (s *SessionModeConfig) GetKeyBindings() map[string]string { | 594 | func (s *SessionModeConfig) GetKeyBindings() map[string]string { | ||
| 585 | keyToAction := make(map[string]string) | 595 | keyToAction := make(map[string]string) | ||
| 586 | - | for action, keys := range s.Actions { | 596 | + | for action, binding := range s.Actions { |
| 587 | - | for _, key := range keys { | 597 | + | for _, key := range binding.Keys { |
| 588 | keyToAction[key] = action | 598 | keyToAction[key] = action | ||
| 589 | } | 599 | } | ||
| 590 | } | 600 | } | ||
M internal/infrastructure/config/schema_provider.go +5 -47
| @@ -445,54 +445,12 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 445 | Description: "Key bindings for resize mode actions", | 445 | Description: "Key bindings for resize mode actions", | ||
| 446 | Section: SectionWorkspace, | 446 | Section: SectionWorkspace, | ||
| 447 | }, | 447 | }, | ||
| 448 | - | // Global shortcuts | 448 | + | // Global shortcuts - managed via dedicated keybindings UI |
| 449 | { | 449 | { | ||
| 450 | - | Key: "workspace.shortcuts.close_pane", | 450 | + | Key: "workspace.shortcuts.actions", |
| 451 | - | Type: "string", | 451 | + | Type: "object", |
| 452 | - | Default: defaults.Workspace.Shortcuts.ClosePane, | 452 | + | Default: "(see defaults)", |
| 453 | - | Description: "Global shortcut to close current pane", | 453 | + | Description: "Global keyboard shortcuts (use Keybindings tab to edit)", |
| 454 | - | Section: SectionWorkspace, | |||
| 455 | - | }, | |||
| 456 | - | { | |||
| 457 | - | Key: "workspace.shortcuts.next_tab", | |||
| 458 | - | Type: "string", | |||
| 459 | - | Default: defaults.Workspace.Shortcuts.NextTab, | |||
| 460 | - | Description: "Global shortcut to switch to next tab", | |||
| 461 | - | Section: SectionWorkspace, | |||
| 462 | - | }, | |||
| 463 | - | { | |||
| 464 | - | Key: "workspace.shortcuts.previous_tab", | |||
| 465 | - | Type: "string", | |||
| 466 | - | Default: defaults.Workspace.Shortcuts.PreviousTab, | |||
| 467 | - | Description: "Global shortcut to switch to previous tab", | |||
| 468 | - | Section: SectionWorkspace, | |||
| 469 | - | }, | |||
| 470 | - | { | |||
| 471 | - | Key: "workspace.shortcuts.consume_or_expel_left", | |||
| 472 | - | Type: "string", | |||
| 473 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelLeft, | |||
| 474 | - | Description: "Consume into left sibling stack or expel left (alpha)", | |||
| 475 | - | Section: SectionWorkspace, | |||
| 476 | - | }, | |||
| 477 | - | { | |||
| 478 | - | Key: "workspace.shortcuts.consume_or_expel_right", | |||
| 479 | - | Type: "string", | |||
| 480 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelRight, | |||
| 481 | - | Description: "Consume into right sibling stack or expel right (alpha)", | |||
| 482 | - | Section: SectionWorkspace, | |||
| 483 | - | }, | |||
| 484 | - | { | |||
| 485 | - | Key: "workspace.shortcuts.consume_or_expel_up", | |||
| 486 | - | Type: "string", | |||
| 487 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelUp, | |||
| 488 | - | Description: "Consume into upper sibling stack or expel up (alpha)", | |||
| 489 | - | Section: SectionWorkspace, | |||
| 490 | - | }, | |||
| 491 | - | { | |||
| 492 | - | Key: "workspace.shortcuts.consume_or_expel_down", | |||
| 493 | - | Type: "string", | |||
| 494 | - | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelDown, | |||
| 495 | - | Description: "Consume into lower sibling stack or expel down (alpha)", | |||
| 496 | Section: SectionWorkspace, | 454 | Section: SectionWorkspace, | ||
| 497 | }, | 455 | }, | ||
| 498 | // Popups | 456 | // Popups | ||
A internal/infrastructure/config/transform_legacy.go +109 -0
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | const ( | |||
| 4 | + | sectionWorkspace = "workspace" | |||
| 5 | + | sectionSession = "session" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // LegacyConfigTransformer implements port.ConfigTransformer. | |||
| 9 | + | // It transforms old-format action bindings (slices) to new ActionBinding format. | |||
| 10 | + | type LegacyConfigTransformer struct { | |||
| 11 | + | defaults *Config | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // NewLegacyConfigTransformer creates a new transformer with defaults for description lookup. | |||
| 15 | + | func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||
| 16 | + | return &LegacyConfigTransformer{defaults: DefaultConfig()} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // TransformLegacyActions converts old-format action bindings to new format. | |||
| 20 | + | // This handles migration from: | |||
| 21 | + | // | |||
| 22 | + | // [workspace.pane_mode.actions] | |||
| 23 | + | // "focus-left" = ["h", "arrowleft"] | |||
| 24 | + | // | |||
| 25 | + | // To: | |||
| 26 | + | // | |||
| 27 | + | // [workspace.pane_mode.actions.focus-left] | |||
| 28 | + | // keys = ["h", "arrowleft"] | |||
| 29 | + | // desc = "Focus pane to the left" | |||
| 30 | + | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |||
| 31 | + | actionPaths := [][]string{ | |||
| 32 | + | {"workspace", "pane_mode", "actions"}, | |||
| 33 | + | {"workspace", "tab_mode", "actions"}, | |||
| 34 | + | {"workspace", "resize_mode", "actions"}, | |||
| 35 | + | {"workspace", "shortcuts", "actions"}, | |||
| 36 | + | {"session", "session_mode", "actions"}, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | for _, path := range actionPaths { | |||
| 40 | + | t.transformActionSection(rawConfig, path) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |||
| 45 | + | // Navigate to the parent of the actions section | |||
| 46 | + | current := rawConfig | |||
| 47 | + | for _, key := range path[:len(path)-1] { | |||
| 48 | + | next, ok := current[key].(map[string]any) | |||
| 49 | + | if !ok { | |||
| 50 | + | return // Path doesn't exist | |||
| 51 | + | } | |||
| 52 | + | current = next | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | actionsKey := path[len(path)-1] | |||
| 56 | + | actionsRaw, ok := current[actionsKey] | |||
| 57 | + | if !ok { | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | actions, ok := actionsRaw.(map[string]any) | |||
| 62 | + | if !ok { | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Check each action and transform if needed | |||
| 67 | + | for actionName, actionValue := range actions { | |||
| 68 | + | // If value is a slice (old format), convert to ActionBinding format | |||
| 69 | + | if slice, ok := actionValue.([]any); ok { | |||
| 70 | + | keys := make([]string, 0, len(slice)) | |||
| 71 | + | for _, v := range slice { | |||
| 72 | + | if s, ok := v.(string); ok { | |||
| 73 | + | keys = append(keys, s) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | actions[actionName] = map[string]any{ | |||
| 78 | + | "keys": keys, | |||
| 79 | + | "desc": t.getDefaultDesc(path, actionName), | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string { | |||
| 86 | + | switch { | |||
| 87 | + | case path[0] == sectionWorkspace && path[1] == "pane_mode": | |||
| 88 | + | if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok { | |||
| 89 | + | return b.Desc | |||
| 90 | + | } | |||
| 91 | + | case path[0] == sectionWorkspace && path[1] == "tab_mode": | |||
| 92 | + | if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok { | |||
| 93 | + | return b.Desc | |||
| 94 | + | } | |||
| 95 | + | case path[0] == sectionWorkspace && path[1] == "resize_mode": | |||
| 96 | + | if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok { | |||
| 97 | + | return b.Desc | |||
| 98 | + | } | |||
| 99 | + | case path[0] == sectionWorkspace && path[1] == "shortcuts": | |||
| 100 | + | if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok { | |||
| 101 | + | return b.Desc | |||
| 102 | + | } | |||
| 103 | + | case path[0] == sectionSession && path[1] == "session_mode": | |||
| 104 | + | if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok { | |||
| 105 | + | return b.Desc | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return "" | |||
| 109 | + | } | |||
A internal/infrastructure/config/transform_legacy_test.go +193 -0
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestTransformLegacyActions_SliceFormat(t *testing.T) { | |||
| 11 | + | transformer := NewLegacyConfigTransformer() | |||
| 12 | + | ||||
| 13 | + | rawConfig := map[string]any{ | |||
| 14 | + | "workspace": map[string]any{ | |||
| 15 | + | "pane_mode": map[string]any{ | |||
| 16 | + | "actions": map[string]any{ | |||
| 17 | + | "focus-left": []any{"h", "arrowleft"}, | |||
| 18 | + | }, | |||
| 19 | + | }, | |||
| 20 | + | }, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 24 | + | ||||
| 25 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 26 | + | binding, ok := actions["focus-left"].(map[string]any) | |||
| 27 | + | require.True(t, ok, "should be converted to map") | |||
| 28 | + | ||||
| 29 | + | keys, ok := binding["keys"].([]string) | |||
| 30 | + | require.True(t, ok, "keys should be []string") | |||
| 31 | + | assert.Equal(t, []string{"h", "arrowleft"}, keys) | |||
| 32 | + | assert.NotEmpty(t, binding["desc"], "should have description from defaults") | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestTransformLegacyActions_AllModes(t *testing.T) { | |||
| 36 | + | transformer := NewLegacyConfigTransformer() | |||
| 37 | + | ||||
| 38 | + | rawConfig := map[string]any{ | |||
| 39 | + | "workspace": map[string]any{ | |||
| 40 | + | "pane_mode": map[string]any{ | |||
| 41 | + | "actions": map[string]any{ | |||
| 42 | + | "focus-left": []any{"h"}, | |||
| 43 | + | }, | |||
| 44 | + | }, | |||
| 45 | + | "tab_mode": map[string]any{ | |||
| 46 | + | "actions": map[string]any{ | |||
| 47 | + | "next-tab": []any{"l"}, | |||
| 48 | + | }, | |||
| 49 | + | }, | |||
| 50 | + | "resize_mode": map[string]any{ | |||
| 51 | + | "actions": map[string]any{ | |||
| 52 | + | "resize-increase": []any{"+"}, | |||
| 53 | + | }, | |||
| 54 | + | }, | |||
| 55 | + | "shortcuts": map[string]any{ | |||
| 56 | + | "actions": map[string]any{ | |||
| 57 | + | "close_pane": []any{"ctrl+w"}, | |||
| 58 | + | }, | |||
| 59 | + | }, | |||
| 60 | + | }, | |||
| 61 | + | "session": map[string]any{ | |||
| 62 | + | "session_mode": map[string]any{ | |||
| 63 | + | "actions": map[string]any{ | |||
| 64 | + | "session-manager": []any{"m"}, | |||
| 65 | + | }, | |||
| 66 | + | }, | |||
| 67 | + | }, | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 71 | + | ||||
| 72 | + | // Check pane mode | |||
| 73 | + | paneActions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 74 | + | paneFocus, ok := paneActions["focus-left"].(map[string]any) | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | assert.Equal(t, []string{"h"}, paneFocus["keys"]) | |||
| 77 | + | ||||
| 78 | + | // Check tab mode | |||
| 79 | + | tabActions := rawConfig["workspace"].(map[string]any)["tab_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 80 | + | tabNext, ok := tabActions["next-tab"].(map[string]any) | |||
| 81 | + | require.True(t, ok) | |||
| 82 | + | assert.Equal(t, []string{"l"}, tabNext["keys"]) | |||
| 83 | + | ||||
| 84 | + | // Check resize mode | |||
| 85 | + | resizeActions := rawConfig["workspace"].(map[string]any)["resize_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 86 | + | resizeInc, ok := resizeActions["resize-increase"].(map[string]any) | |||
| 87 | + | require.True(t, ok) | |||
| 88 | + | assert.Equal(t, []string{"+"}, resizeInc["keys"]) | |||
| 89 | + | ||||
| 90 | + | // Check shortcuts | |||
| 91 | + | shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any) | |||
| 92 | + | closePane, ok := shortcutActions["close_pane"].(map[string]any) | |||
| 93 | + | require.True(t, ok) | |||
| 94 | + | assert.Equal(t, []string{"ctrl+w"}, closePane["keys"]) | |||
| 95 | + | ||||
| 96 | + | // Check session mode | |||
| 97 | + | sessionActions := rawConfig["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 98 | + | sessionMgr, ok := sessionActions["session-manager"].(map[string]any) | |||
| 99 | + | require.True(t, ok) | |||
| 100 | + | assert.Equal(t, []string{"m"}, sessionMgr["keys"]) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestTransformLegacyActions_AlreadyNewFormat(t *testing.T) { | |||
| 104 | + | transformer := NewLegacyConfigTransformer() | |||
| 105 | + | ||||
| 106 | + | rawConfig := map[string]any{ | |||
| 107 | + | "workspace": map[string]any{ | |||
| 108 | + | "pane_mode": map[string]any{ | |||
| 109 | + | "actions": map[string]any{ | |||
| 110 | + | "focus-left": map[string]any{ | |||
| 111 | + | "keys": []string{"h"}, | |||
| 112 | + | "desc": "Custom description", | |||
| 113 | + | }, | |||
| 114 | + | }, | |||
| 115 | + | }, | |||
| 116 | + | }, | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 120 | + | ||||
| 121 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 122 | + | binding := actions["focus-left"].(map[string]any) | |||
| 123 | + | ||||
| 124 | + | // Should remain unchanged | |||
| 125 | + | assert.Equal(t, "Custom description", binding["desc"]) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestTransformLegacyActions_MissingSection(t *testing.T) { | |||
| 129 | + | transformer := NewLegacyConfigTransformer() | |||
| 130 | + | ||||
| 131 | + | rawConfig := map[string]any{ | |||
| 132 | + | "appearance": map[string]any{ | |||
| 133 | + | "theme": "dark", | |||
| 134 | + | }, | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Should not panic | |||
| 138 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 139 | + | ||||
| 140 | + | // Config should be unchanged | |||
| 141 | + | assert.Equal(t, "dark", rawConfig["appearance"].(map[string]any)["theme"]) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestTransformLegacyActions_EmptyConfig(t *testing.T) { | |||
| 145 | + | transformer := NewLegacyConfigTransformer() | |||
| 146 | + | ||||
| 147 | + | rawConfig := map[string]any{} | |||
| 148 | + | ||||
| 149 | + | // Should not panic | |||
| 150 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 151 | + | ||||
| 152 | + | assert.Empty(t, rawConfig) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestTransformLegacyActions_PartialPath(t *testing.T) { | |||
| 156 | + | transformer := NewLegacyConfigTransformer() | |||
| 157 | + | ||||
| 158 | + | rawConfig := map[string]any{ | |||
| 159 | + | "workspace": map[string]any{ | |||
| 160 | + | "pane_mode": map[string]any{ | |||
| 161 | + | // actions section missing | |||
| 162 | + | }, | |||
| 163 | + | }, | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Should not panic | |||
| 167 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 168 | + | ||||
| 169 | + | // Verify config is unchanged | |||
| 170 | + | require.NotNil(t, rawConfig["workspace"]) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |||
| 174 | + | transformer := NewLegacyConfigTransformer() | |||
| 175 | + | ||||
| 176 | + | rawConfig := map[string]any{ | |||
| 177 | + | "workspace": map[string]any{ | |||
| 178 | + | "pane_mode": map[string]any{ | |||
| 179 | + | "actions": map[string]any{ | |||
| 180 | + | "unknown-action": []any{"x"}, | |||
| 181 | + | }, | |||
| 182 | + | }, | |||
| 183 | + | }, | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | transformer.TransformLegacyActions(rawConfig) | |||
| 187 | + | ||||
| 188 | + | actions := rawConfig["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 189 | + | binding, ok := actions["unknown-action"].(map[string]any) | |||
| 190 | + | require.True(t, ok) | |||
| 191 | + | assert.Equal(t, []string{"x"}, binding["keys"]) | |||
| 192 | + | assert.Empty(t, binding["desc"], "unknown action should have empty description") | |||
| 193 | + | } | |||
M internal/infrastructure/config/validation.go +6 -6
| @@ -166,11 +166,11 @@ func validatePaneMode(config *Config) []string { | |||||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | seenKeys := make(map[string]string) | 168 | seenKeys := make(map[string]string) | ||
| 169 | - | for action, keys := range config.Workspace.PaneMode.Actions { | 169 | + | for action, binding := range config.Workspace.PaneMode.Actions { |
| 170 | - | if len(keys) == 0 { | 170 | + | if len(binding.Keys) == 0 { |
| 171 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action)) | 171 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action)) | ||
| 172 | } | 172 | } | ||
| 173 | - | for _, key := range keys { | 173 | + | for _, key := range binding.Keys { |
| 174 | if existingAction, exists := seenKeys[key]; exists { | 174 | if existingAction, exists := seenKeys[key]; exists { | ||
| 175 | validationErrors = append(validationErrors, fmt.Sprintf( | 175 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 176 | "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", | 176 | "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", | ||
| @@ -204,11 +204,11 @@ func validateTabMode(config *Config) []string { | |||||
| 204 | } | 204 | } | ||
| 205 | 205 | ||||
| 206 | tabSeenKeys := make(map[string]string) | 206 | tabSeenKeys := make(map[string]string) | ||
| 207 | - | for action, keys := range config.Workspace.TabMode.Actions { | 207 | + | for action, binding := range config.Workspace.TabMode.Actions { |
| 208 | - | if len(keys) == 0 { | 208 | + | if len(binding.Keys) == 0 { |
| 209 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action)) | 209 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action)) | ||
| 210 | } | 210 | } | ||
| 211 | - | for _, key := range keys { | 211 | + | for _, key := range binding.Keys { |
| 212 | if existingAction, exists := tabSeenKeys[key]; exists { | 212 | if existingAction, exists := tabSeenKeys[key]; exists { | ||
| 213 | validationErrors = append(validationErrors, fmt.Sprintf( | 213 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 214 | "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", | 214 | "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", | ||
M internal/infrastructure/config/watcher.go +47 -14
| @@ -2,8 +2,8 @@ package config | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | - | "os" | |||
| 6 | 5 | ||||
| 6 | + | "github.com/bnema/dumber/internal/logging" | |||
| 7 | "github.com/fsnotify/fsnotify" | 7 | "github.com/fsnotify/fsnotify" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| @@ -17,29 +17,62 @@ func (m *Manager) Watch() error { | |||||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | m.viper.WatchConfig() | 19 | m.viper.WatchConfig() | ||
| 20 | - | m.viper.OnConfigChange(func(_ fsnotify.Event) { | 20 | + | m.viper.OnConfigChange(func(e fsnotify.Event) { |
| 21 | - | // Reload config | 21 | + | log := logging.NewFromEnv() |
| 22 | - | if err := m.reload(); err != nil { | 22 | + | log.Debug().Str("op", e.Op.String()).Str("file", e.Name).Msg("fsnotify config change detected") |
| 23 | - | fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err) | 23 | + | |
| 24 | + | // Acquire write lock before reload (reload modifies m.config) | |||
| 25 | + | m.mu.Lock() | |||
| 26 | + | ||||
| 27 | + | // 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 | return | 47 | return | ||
| 25 | } | 48 | } | ||
| 26 | 49 | ||||
| 27 | - | // Notify callbacks | 50 | + | log.Debug().Msg("reloading config from external change") |
| 28 | - | m.mu.RLock() | 51 | + | if err := m.reload(); err != nil { |
| 29 | - | config := m.config | 52 | + | log.Warn().Err(err).Msg("failed to reload config") |
| 30 | - | callbacks := make([]func(*Config), len(m.callbacks)) | 53 | + | return // defer will unlock |
| 31 | - | copy(callbacks, m.callbacks) | |||
| 32 | - | m.mu.RUnlock() | |||
| 33 | - | ||||
| 34 | - | for _, callback := range callbacks { | |||
| 35 | - | callback(config) | |||
| 36 | } | 54 | } | ||
| 55 | + | willNotify = true | |||
| 56 | + | m.notifyCallbacksLocked() | |||
| 37 | }) | 57 | }) | ||
| 38 | 58 | ||||
| 39 | m.watching = true | 59 | m.watching = true | ||
| 40 | return nil | 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 | // OnConfigChange registers a callback function to be called when config changes. | 76 | // OnConfigChange registers a callback function to be called when config changes. | ||
| 44 | func (m *Manager) OnConfigChange(callback func(*Config)) { | 77 | func (m *Manager) OnConfigChange(callback func(*Config)) { | ||
| 45 | m.mu.Lock() | 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 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | ||
| 51 | log := logging.FromContext(ctx) | 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 | // Split query into words and add prefix matching to each | 58 | // Split query into words and add prefix matching to each | ||
| 54 | // This enables multi-word searches like "github issues" -> "github* issues*" | 59 | // This enables multi-word searches like "github issues" -> "github* issues*" | ||
| 55 | - | words := strings.Fields(query) | 60 | + | words := strings.Fields(normalizedQuery) |
| 56 | if len(words) == 0 { | 61 | if len(words) == 0 { | ||
| 57 | return []entity.HistoryMatch{}, nil | 62 | return []entity.HistoryMatch{}, nil | ||
| 58 | } | 63 | } | ||
| @@ -66,13 +71,16 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 66 | ftsQuery := strings.Join(parts, " ") | 71 | ftsQuery := strings.Join(parts, " ") | ||
| 67 | 72 | ||||
| 68 | // Search both URL and title columns, then merge results | 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 | Query: ftsQuery, | 78 | Query: ftsQuery, | ||
| 71 | Limit: int64(limit), | 79 | Limit: int64(limit), | ||
| 72 | }) | 80 | }) | ||
| 73 | if urlErr != nil { | 81 | if urlErr != nil { | ||
| 74 | log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed") | 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 | titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{ | 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 | if !seen[row.ID] { | 105 | if !seen[row.ID] { | ||
| 98 | seen[row.ID] = true | 106 | seen[row.ID] = true | ||
| 99 | matches = append(matches, entity.HistoryMatch{ | 107 | matches = append(matches, entity.HistoryMatch{ | ||
| 100 | - | Entry: historyFromRow(row), | 108 | + | Entry: historyFromDomainBoostRow(row), |
| 101 | Score: 1.0, | 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 | return matches, nil | 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 | // sanitizeFTS5Word removes FTS5 special characters from a search word. | 143 | // sanitizeFTS5Word removes FTS5 special characters from a search word. | ||
| 124 | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | 144 | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | ||
| 145 | + | // Note: Periods are handled earlier by replacing with spaces (tokenizer separator) | |||
| 125 | func sanitizeFTS5Word(word string) string { | 146 | func sanitizeFTS5Word(word string) string { | ||
| 126 | // Remove characters that have special meaning in FTS5 | 147 | // Remove characters that have special meaning in FTS5 | ||
| 127 | var result strings.Builder | 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 | assert.True(t, urls["https://github.com"]) | 484 | assert.True(t, urls["https://github.com"]) | ||
| 485 | assert.True(t, urls["https://gitlab.com"]) | 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 | ORDER BY h.visit_count DESC, h.last_visited DESC | 57 | ORDER BY h.visit_count DESC, h.last_visited DESC | ||
| 58 | LIMIT @limit; | 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 | -- name: SearchHistoryFTSTitle :many | 75 | -- name: SearchHistoryFTSTitle :many | ||
| 61 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 76 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | ||
| 62 | FROM history_fts fts | 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 | return items, nil | 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 | const UpsertHistory = `-- name: UpsertHistory :exec | 597 | const UpsertHistory = `-- name: UpsertHistory :exec | ||
| 533 | INSERT INTO history (url, title, favicon_url) | 598 | INSERT INTO history (url, title, favicon_url) | ||
| 534 | VALUES (?, ?, ?) | 599 | VALUES (?, ?, ?) | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
| @@ -72,6 +72,7 @@ type Querier interface { | |||||
| 72 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | 72 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | ||
| 73 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | 73 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | ||
| 74 | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | 74 | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | ||
| 75 | + | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | |||
| 75 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | 76 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | ||
| 76 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | 77 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | ||
| 77 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | 78 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | ||
M internal/infrastructure/webkit/download_handler.go +58 -44
| @@ -4,11 +4,11 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | - | "path/filepath" | |||
| 8 | - | "strings" | |||
| 9 | "sync" | 7 | "sync" | ||
| 10 | 8 | ||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 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 | "github.com/bnema/dumber/internal/logging" | 12 | "github.com/bnema/dumber/internal/logging" | ||
| 13 | "github.com/bnema/puregotk-webkit/webkit" | 13 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 14 | ) | 14 | ) | ||
| @@ -20,16 +20,26 @@ const ( | |||||
| 20 | 20 | ||||
| 21 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | 21 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | ||
| 22 | type DownloadHandler struct { | 22 | type DownloadHandler struct { | ||
| 23 | - | downloadPath string | 23 | + | downloadPath string |
| 24 | - | eventHandler port.DownloadEventHandler | 24 | + | eventHandler port.DownloadEventHandler |
| 25 | - | mu sync.RWMutex | 25 | + | prepareDownloadUC *usecase.PrepareDownloadUseCase |
| 26 | + | mu sync.RWMutex | |||
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 28 | // NewDownloadHandler creates a new download handler. | 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 | return &DownloadHandler{ | 39 | return &DownloadHandler{ | ||
| 31 | - | downloadPath: downloadPath, | 40 | + | downloadPath: downloadPath, |
| 32 | - | eventHandler: handler, | 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 | h.mu.RLock() | 57 | h.mu.RLock() | ||
| 48 | downloadPath := h.downloadPath | 58 | downloadPath := h.downloadPath | ||
| 49 | eventHandler := h.eventHandler | 59 | eventHandler := h.eventHandler | ||
| 60 | + | prepareDownloadUC := h.prepareDownloadUC | |||
| 50 | h.mu.RUnlock() | 61 | h.mu.RUnlock() | ||
| 51 | 62 | ||||
| 52 | // Track download state in a dedicated struct to ensure proper isolation. | 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 | return false | 74 | return false | ||
| 64 | } | 75 | } | ||
| 65 | 76 | ||||
| 66 | - | // Sanitize filename to prevent path traversal attacks. | 77 | + | // Wrap WebKit response as port.DownloadResponse |
| 67 | - | safeName := sanitizeFilename(suggestedFilename) | 78 | + | var response port.DownloadResponse |
| 68 | - | destPath := filepath.Join(downloadPath, safeName) | 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 | log.Debug(). | 90 | log.Debug(). | ||
| 71 | Str("suggested", suggestedFilename). | 91 | Str("suggested", suggestedFilename). | ||
| 72 | - | Str("sanitized", safeName). | 92 | + | Str("sanitized", output.Filename). |
| 73 | - | Str("destPath", destPath). | 93 | + | Str("destPath", output.DestinationPath). |
| 74 | Msg("setting download destination") | 94 | Msg("setting download destination") | ||
| 75 | 95 | ||||
| 76 | // WebKit expects an absolute file path for the destination. | 96 | // WebKit expects an absolute file path for the destination. | ||
| 77 | - | d.SetDestination(destPath) | 97 | + | d.SetDestination(output.DestinationPath) |
| 78 | 98 | ||||
| 79 | // Notify: download started. | 99 | // Notify: download started. | ||
| 80 | if eventHandler != nil { | 100 | if eventHandler != nil { | ||
| 81 | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | 101 | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | ||
| 82 | Type: port.DownloadEventStarted, | 102 | Type: port.DownloadEventStarted, | ||
| 83 | - | Filename: safeName, | 103 | + | Filename: output.Filename, |
| 84 | - | Destination: destPath, | 104 | + | Destination: output.DestinationPath, |
| 85 | }) | 105 | }) | ||
| 86 | } | 106 | } | ||
| 87 | 107 | ||||
| 88 | log.Info(). | 108 | log.Info(). | ||
| 89 | - | Str("filename", safeName). | 109 | + | Str("filename", output.Filename). |
| 90 | - | Str("destination", destPath). | 110 | + | Str("destination", output.DestinationPath). |
| 91 | Msg("download started") | 111 | Msg("download started") | ||
| 92 | 112 | ||||
| 93 | return false // false = we handled it synchronously | 113 | return false // false = we handled it synchronously | ||
| @@ -101,7 +121,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||||
| 101 | state.mu.Unlock() | 121 | state.mu.Unlock() | ||
| 102 | 122 | ||||
| 103 | dest := d.GetDestination() | 123 | dest := d.GetDestination() | ||
| 104 | - | filename := extractFilename(dest) | 124 | + | filename := downloadutil.ExtractFilenameFromDestination(dest) |
| 105 | 125 | ||||
| 106 | // Create error for the failed download. | 126 | // Create error for the failed download. | ||
| 107 | downloadErr := fmt.Errorf("download failed: %s", filename) | 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 | dest := d.GetDestination() | 153 | dest := d.GetDestination() | ||
| 134 | - | filename := extractFilename(dest) | 154 | + | filename := downloadutil.ExtractFilenameFromDestination(dest) |
| 135 | 155 | ||||
| 136 | if eventHandler != nil { | 156 | if eventHandler != nil { | ||
| 137 | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | 157 | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | ||
| @@ -153,34 +173,28 @@ func (h *DownloadHandler) SetDownloadPath(path string) { | |||||
| 153 | h.downloadPath = path | 173 | h.downloadPath = path | ||
| 154 | } | 174 | } | ||
| 155 | 175 | ||||
| 156 | - | // extractFilename extracts the filename from a file:// URI or path. | 176 | + | // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse. |
| 157 | - | func extractFilename(dest string) string { | 177 | + | type uriResponseAdapter struct { |
| 158 | - | // Remove file:// prefix if present. | 178 | + | resp *webkit.URIResponse |
| 159 | - | path := strings.TrimPrefix(dest, "file://") | 179 | + | } |
| 160 | - | base := filepath.Base(path) | |||
| 161 | 180 | ||||
| 162 | - | // Handle edge cases consistently with sanitizeFilename. | 181 | + | func (a *uriResponseAdapter) GetMimeType() string { |
| 163 | - | if base == "." || base == "" { | 182 | + | if a.resp == nil { |
| 164 | - | return "download" | 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. | 188 | + | func (a *uriResponseAdapter) GetSuggestedFilename() string { |
| 170 | - | // It extracts only the base name and handles edge cases like "." or "..". | 189 | + | if a.resp == nil { |
| 171 | - | func sanitizeFilename(name string) string { | 190 | + | return "" |
| 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" | |||
| 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 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 6 | "github.com/stretchr/testify/assert" | 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 | func TestNewDownloadHandler(t *testing.T) { | 10 | func TestNewDownloadHandler(t *testing.T) { | ||
| 11 | + | prepareUC := usecase.NewPrepareDownloadUseCase(nil) | |||
| 12 | + | ||||
| 127 | t.Run("creates handler with path and nil event handler", func(t *testing.T) { | 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 | assert.NotNil(t, handler) | 16 | assert.NotNil(t, handler) | ||
| 131 | assert.Equal(t, "/tmp/downloads", handler.downloadPath) | 17 | assert.Equal(t, "/tmp/downloads", handler.downloadPath) | ||
| 132 | assert.Nil(t, handler.eventHandler) | 18 | assert.Nil(t, handler.eventHandler) | ||
| 19 | + | assert.NotNil(t, handler.prepareDownloadUC) | |||
| 133 | }) | 20 | }) | ||
| 134 | 21 | ||||
| 135 | t.Run("creates handler with custom path", func(t *testing.T) { | 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 | assert.Equal(t, "/custom/path", handler.downloadPath) | 25 | assert.Equal(t, "/custom/path", handler.downloadPath) | ||
| 139 | }) | 26 | }) | ||
| 140 | } | 27 | } | ||
| 141 | 28 | ||||
| 142 | func TestDownloadHandler_SetDownloadPath(t *testing.T) { | 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 | handler.SetDownloadPath("/new/path") | 33 | handler.SetDownloadPath("/new/path") | ||
| 146 | 34 | ||||
| 147 | assert.Equal(t, "/new/path", handler.downloadPath) | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 8 | "github.com/bnema/dumber/internal/application/usecase" | ||
| @@ -36,6 +37,15 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 36 | return err | 37 | return err | ||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| 40 | + | // Keybindings handlers (always available) | |||
| 41 | + | keybindingsHandler, err := createKeybindingsHandler() | |||
| 42 | + | if err != nil { | |||
| 43 | + | return fmt.Errorf("failed to create keybindings handler: %w", err) | |||
| 44 | + | } | |||
| 45 | + | if err := RegisterKeybindingsHandlers(ctx, router, keybindingsHandler); err != nil { | |||
| 46 | + | return err | |||
| 47 | + | } | |||
| 48 | + | ||||
| 39 | // Clipboard handlers (for auto-copy on selection feature) | 49 | // Clipboard handlers (for auto-copy on selection feature) | ||
| 40 | if cfg.Clipboard != nil && cfg.ConfigGetter != nil { | 50 | if cfg.Clipboard != nil && cfg.ConfigGetter != nil { | ||
| 41 | if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil { | 51 | if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil { | ||
| @@ -50,3 +60,20 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 50 | 60 | ||||
| 51 | return nil | 61 | return nil | ||
| 52 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | // createKeybindingsHandler wires the gateway and use cases for keybindings. | |||
| 65 | + | func createKeybindingsHandler() (*KeybindingsHandler, error) { | |||
| 66 | + | mgr := config.GetManager() | |||
| 67 | + | if mgr == nil { | |||
| 68 | + | return nil, fmt.Errorf("config manager not initialized") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | gateway := config.NewKeybindingsGateway(mgr) | |||
| 72 | + | ||||
| 73 | + | return NewKeybindingsHandler( | |||
| 74 | + | usecase.NewGetKeybindingsUseCase(gateway), | |||
| 75 | + | usecase.NewSetKeybindingUseCase(gateway, gateway), | |||
| 76 | + | usecase.NewResetKeybindingUseCase(gateway), | |||
| 77 | + | usecase.NewResetAllKeybindingsUseCase(gateway), | |||
| 78 | + | ), nil | |||
| 79 | + | } | |||
M internal/infrastructure/webkit/messaging.go +19 -3
| @@ -174,6 +174,8 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||||
| 174 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | 174 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | ||
| 175 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | 175 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | ||
| 176 | 176 | ||||
| 177 | + | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | |||
| 178 | + | ||||
| 177 | if valuePtr == 0 { | 179 | if valuePtr == 0 { | ||
| 178 | log.Warn().Msg("received script message with nil value pointer") | 180 | log.Warn().Msg("received script message with nil value pointer") | ||
| 179 | return | 181 | return | ||
| @@ -186,8 +188,14 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 186 | } | 188 | } | ||
| 187 | 189 | ||||
| 188 | rawJSON := jscValue.ToJson(0) | 190 | rawJSON := jscValue.ToJson(0) | ||
| 191 | + | log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result") | |||
| 189 | if rawJSON == "" { | 192 | if rawJSON == "" { | ||
| 190 | - | log.Warn().Msg("script message JSON is empty") | 193 | + | // This typically happens when the JS side sends non-serializable values |
| 194 | + | // like Svelte 5 Proxy objects or functions. The frontend should use | |||
| 195 | + | // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects. | |||
| 196 | + | log.Error(). | |||
| 197 | + | Msg("script message JSON serialization failed (ToJson returned empty) - " + | |||
| 198 | + | "this usually means JS sent non-serializable values like Proxy objects") | |||
| 191 | return | 199 | return | ||
| 192 | } | 200 | } | ||
| 193 | 201 | ||||
| @@ -213,7 +221,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 213 | 221 | ||||
| 214 | entry, ok := r.getHandler(msg.Type) | 222 | entry, ok := r.getHandler(msg.Type) | ||
| 215 | if !ok || entry.handler == nil { | 223 | if !ok || entry.handler == nil { | ||
| 216 | - | log.Warn().Str("type", msg.Type).Msg("no handler registered for message type") | 224 | + | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type") |
| 225 | + | // Debug: list all registered handlers | |||
| 226 | + | r.mu.RLock() | |||
| 227 | + | for handlerType := range r.handlers { | |||
| 228 | + | log.Debug().Str("registered_type", handlerType).Msg("available handler") | |||
| 229 | + | } | |||
| 230 | + | r.mu.RUnlock() | |||
| 217 | return | 231 | return | ||
| 218 | } | 232 | } | ||
| 219 | 233 | ||||
| @@ -221,9 +235,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 221 | Str("type", msg.Type). | 235 | Str("type", msg.Type). | ||
| 222 | Uint64("webview_id", msg.WebViewID). | 236 | Uint64("webview_id", msg.WebViewID). | ||
| 223 | Int("payload_len", len(msg.Payload)). | 237 | Int("payload_len", len(msg.Payload)). | ||
| 224 | - | Msg("received script message") | 238 | + | Str("callback", entry.callback). |
| 239 | + | Msg("received script message, dispatching to handler") | |||
| 225 | 240 | ||||
| 226 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | 241 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | ||
| 242 | + | log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") | |||
| 227 | if err != nil { | 243 | if err != nil { | ||
| 228 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | 244 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | ||
| 229 | if entry.errorCallback != "" { | 245 | if entry.errorCallback != "" { | ||
M internal/infrastructure/webkit/webview.go +145 -41
| @@ -3,6 +3,8 @@ package webkit | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "net/url" | |||
| 7 | + | "strings" | |||
| 6 | "sync" | 8 | "sync" | ||
| 7 | "sync/atomic" | 9 | "sync/atomic" | ||
| 8 | "time" | 10 | "time" | ||
| @@ -552,60 +554,156 @@ func (wv *WebView) connectProgressSignal() { | |||||
| 552 | 554 | ||||
| 553 | func (wv *WebView) connectDecidePolicySignal() { | 555 | func (wv *WebView) connectDecidePolicySignal() { | ||
| 554 | decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool { | 556 | decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool { | ||
| 555 | - | if decisionType != webkit.PolicyDecisionTypeNavigationActionValue { | 557 | + | switch decisionType { |
| 556 | - | return false // Let WebKit handle | 558 | + | case webkit.PolicyDecisionTypeResponseValue: |
| 557 | - | } | 559 | + | return wv.handleResponsePolicyDecision(decisionPtr) |
| 558 | - | 560 | + | case webkit.PolicyDecisionTypeNavigationActionValue: | |
| 559 | - | navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr) | 561 | + | return wv.handleNavigationPolicyDecision(decisionPtr) |
| 560 | - | if navDecision == nil { | 562 | + | default: |
| 561 | return false | 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() | 570 | + | // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads). |
| 565 | - | if navAction == nil { | 571 | + | func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool { |
| 566 | - | return false | 572 | + | responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr) |
| 567 | - | } | 573 | + | if responseDecision == nil { |
| 574 | + | return false | |||
| 575 | + | } | |||
| 568 | 576 | ||||
| 569 | - | if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue { | 577 | + | if !responseDecision.IsMainFrameMainResource() { |
| 570 | - | return false | 578 | + | return false |
| 571 | - | } | 579 | + | } |
| 572 | 580 | ||||
| 573 | - | mouseButton := navAction.GetMouseButton() | 581 | + | if !shouldForceDownload(responseDecision) { |
| 574 | - | modifiers := navAction.GetModifiers() | 582 | + | return false |
| 575 | - | isMiddleClick := mouseButton == 2 | 583 | + | } |
| 576 | - | isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0 | |||
| 577 | 584 | ||||
| 578 | - | if !isMiddleClick && !isCtrlClick { | 585 | + | policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr) |
| 579 | - | return false | 586 | + | if policyDecision == nil { |
| 580 | - | } | 587 | + | return false |
| 588 | + | } | |||
| 581 | 589 | ||||
| 582 | - | request := navAction.GetRequest() | 590 | + | response := responseDecision.GetResponse() |
| 583 | - | if request == nil { | 591 | + | mimeType := "" |
| 584 | - | return false | 592 | + | uri := "" |
| 585 | - | } | 593 | + | if response != nil { |
| 594 | + | mimeType = response.GetMimeType() | |||
| 595 | + | uri = response.GetUri() | |||
| 596 | + | } | |||
| 586 | 597 | ||||
| 587 | - | linkURI := request.GetUri() | 598 | + | wv.logger.Debug(). |
| 588 | - | if linkURI == "" { | 599 | + | Str("mime_type", mimeType). |
| 589 | - | return false | 600 | + | Str("uri", uri). |
| 590 | - | } | 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(). | 625 | + | // Fallback to old behavior if URI is not available |
| 593 | - | Str("uri", linkURI). | 626 | + | policyDecision.Download() |
| 594 | - | Uint("button", mouseButton). | 627 | + | return true |
| 595 | - | Bool("ctrl", isCtrlClick). | 628 | + | } |
| 596 | - | Msg("middle-click/ctrl+click on link detected") | 629 | + | |
| 597 | - | 630 | + | // handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click). | |
| 598 | - | if wv.OnLinkMiddleClick != nil { | 631 | + | func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool { |
| 599 | - | if wv.OnLinkMiddleClick(linkURI) { | 632 | + | navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr) |
| 600 | - | navDecision.Ignore() | 633 | + | if navDecision == nil { |
| 601 | - | return true | 634 | + | return false |
| 602 | - | } | 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 | return false | 683 | return false | ||
| 606 | } | 684 | } | ||
| 607 | - | sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb) | 685 | + | |
| 608 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 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 | func (wv *WebView) connectEnterFullscreenSignal() { | 709 | func (wv *WebView) connectEnterFullscreenSignal() { | ||
| @@ -905,6 +1003,12 @@ func (wv *WebView) SetBackgroundColor(r, g, b, a float32) { | |||||
| 905 | wv.inner.SetBackgroundColor(rgba) | 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 | // Show makes the WebView widget visible. | 1012 | // Show makes the WebView widget visible. | ||
| 909 | // This should be called after the WebView is ready to be displayed. | 1013 | // This should be called after the WebView is ready to be displayed. | ||
| 910 | func (wv *WebView) Show() { | 1014 | func (wv *WebView) Show() { | ||
M internal/ui/app.go +14 -2
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/config" | 14 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 15 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 17 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | 18 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | ||
| 18 | "github.com/bnema/dumber/internal/infrastructure/textinput" | 19 | "github.com/bnema/dumber/internal/infrastructure/textinput" | ||
| @@ -495,8 +496,11 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||||
| 495 | // Create download event adapter to show toasts. | 496 | // Create download event adapter to show toasts. | ||
| 496 | eventAdapter := &downloadEventAdapter{app: a} | 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 | // Create and wire the download handler. | 502 | // Create and wire the download handler. | ||
| 499 | - | handler := webkit.NewDownloadHandler(downloadPath, eventAdapter) | 503 | + | handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC) |
| 500 | a.deps.WebContext.SetDownloadHandler(ctx, handler) | 504 | a.deps.WebContext.SetDownloadHandler(ctx, handler) | ||
| 501 | 505 | ||||
| 502 | log.Info().Str("path", downloadPath).Msg("download handler initialized") | 506 | log.Info().Str("path", downloadPath).Msg("download handler initialized") | ||
| @@ -590,6 +594,9 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 590 | } | 594 | } | ||
| 591 | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | 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 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | 600 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | ||
| 594 | a.omniboxCfg = component.OmniboxConfig{ | 601 | a.omniboxCfg = component.OmniboxConfig{ | ||
| 595 | HistoryUC: a.deps.HistoryUC, | 602 | HistoryUC: a.deps.HistoryUC, | ||
| @@ -597,6 +604,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 597 | FaviconAdapter: a.faviconAdapter, | 604 | FaviconAdapter: a.faviconAdapter, | ||
| 598 | CopyURLUC: a.deps.CopyURLUC, | 605 | CopyURLUC: a.deps.CopyURLUC, | ||
| 599 | ShortcutsUC: shortcutsUC, | 606 | ShortcutsUC: shortcutsUC, | ||
| 607 | + | AutocompleteUC: autocompleteUC, | |||
| 600 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | 608 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | ||
| 601 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | 609 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | ||
| 602 | UIScale: a.deps.Config.DefaultUIScale, | 610 | UIScale: a.deps.Config.DefaultUIScale, | ||
| @@ -1942,11 +1950,15 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||||
| 1942 | return | 1950 | return | ||
| 1943 | } | 1951 | } | ||
| 1944 | 1952 | ||||
| 1945 | - | // Only appearance is hot-reloaded for now. | 1953 | + | // Hot-reload appearance and keybindings on config change. |
| 1946 | a.configManager.OnConfigChange(func(newCfg *config.Config) { | 1954 | a.configManager.OnConfigChange(func(newCfg *config.Config) { | ||
| 1947 | cfgCopy := newCfg | 1955 | cfgCopy := newCfg | ||
| 1948 | cb := glib.SourceFunc(func(_ uintptr) bool { | 1956 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 1949 | a.applyAppearanceConfig(ctx, cfgCopy) | 1957 | a.applyAppearanceConfig(ctx, cfgCopy) | ||
| 1958 | + | // Reload keyboard shortcuts | |||
| 1959 | + | if a.keyboardHandler != nil { | |||
| 1960 | + | a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy) | |||
| 1961 | + | } | |||
| 1950 | return false | 1962 | return false | ||
| 1951 | }) | 1963 | }) | ||
| 1952 | glib.IdleAdd(&cb, 0) | 1964 | glib.IdleAdd(&cb, 0) | ||
M internal/ui/component/omnibox.go +517 -43
| @@ -3,11 +3,14 @@ package component | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "time" | 9 | "time" | ||
| 10 | + | "unicode/utf8" | |||
| 9 | 11 | ||||
| 10 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 13 | + | "github.com/bnema/dumber/internal/domain/autocomplete" | |||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 14 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | "github.com/bnema/dumber/internal/domain/url" | 15 | "github.com/bnema/dumber/internal/domain/url" | ||
| 13 | "github.com/bnema/dumber/internal/logging" | 16 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -16,11 +19,13 @@ import ( | |||||
| 16 | "github.com/bnema/dumber/internal/ui/layout" | 19 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 17 | "github.com/jwijenbergh/puregotk/v4/gdk" | 20 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 18 | "github.com/jwijenbergh/puregotk/v4/glib" | 21 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 22 | + | "github.com/jwijenbergh/puregotk/v4/graphene" | |||
| 19 | "github.com/jwijenbergh/puregotk/v4/gtk" | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 24 | + | "github.com/jwijenbergh/puregotk/v4/pango" | |||
| 20 | ) | 25 | ) | ||
| 21 | 26 | ||||
| 22 | const ( | 27 | const ( | ||
| 23 | - | debounceDelayMs = 150 | 28 | + | debounceDelayMs = 50 |
| 24 | endBoxSpacing = 6 | 29 | endBoxSpacing = 6 | ||
| 25 | ) | 30 | ) | ||
| 26 | 31 | ||||
| @@ -67,6 +72,8 @@ type Omnibox struct { | |||||
| 67 | zoomLabel *gtk.Label | 72 | zoomLabel *gtk.Label | ||
| 68 | bangBadge *gtk.Label | 73 | bangBadge *gtk.Label | ||
| 69 | entry *gtk.SearchEntry | 74 | entry *gtk.SearchEntry | ||
| 75 | + | entryOverlay *gtk.Overlay // Overlay for ghost text | |||
| 76 | + | ghostLabel *gtk.Label // Ghost text completion label | |||
| 70 | scrolledWin *gtk.ScrolledWindow | 77 | scrolledWin *gtk.ScrolledWindow | ||
| 71 | listBox *gtk.ListBox | 78 | listBox *gtk.ListBox | ||
| 72 | 79 | ||||
| @@ -85,12 +92,20 @@ type Omnibox struct { | |||||
| 85 | detectedBang string | 92 | detectedBang string | ||
| 86 | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | 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 | // Dependencies | 102 | // Dependencies | ||
| 89 | historyUC *usecase.SearchHistoryUseCase | 103 | historyUC *usecase.SearchHistoryUseCase | ||
| 90 | favoritesUC *usecase.ManageFavoritesUseCase | 104 | favoritesUC *usecase.ManageFavoritesUseCase | ||
| 91 | faviconAdapter *adapter.FaviconAdapter | 105 | faviconAdapter *adapter.FaviconAdapter | ||
| 92 | copyURLUC *usecase.CopyURLUseCase | 106 | copyURLUC *usecase.CopyURLUseCase | ||
| 93 | shortcutsUC *usecase.SearchShortcutsUseCase | 107 | shortcutsUC *usecase.SearchShortcutsUseCase | ||
| 108 | + | autocompleteUC *usecase.AutocompleteUseCase | |||
| 94 | defaultSearch string | 109 | defaultSearch string | ||
| 95 | initialBehavior string | 110 | initialBehavior string | ||
| 96 | ctx context.Context | 111 | ctx context.Context | ||
| @@ -118,6 +133,10 @@ type Omnibox struct { | |||||
| 118 | 133 | ||||
| 119 | // Callback retention: keep GTK signal callbacks reachable by Go GC. | 134 | // Callback retention: keep GTK signal callbacks reachable by Go GC. | ||
| 120 | retainedCallbacks []interface{} | 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 | // OmniboxConfig holds configuration for creating an Omnibox. | 142 | // OmniboxConfig holds configuration for creating an Omnibox. | ||
| @@ -127,6 +146,7 @@ type OmniboxConfig struct { | |||||
| 127 | FaviconAdapter *adapter.FaviconAdapter | 146 | FaviconAdapter *adapter.FaviconAdapter | ||
| 128 | CopyURLUC *usecase.CopyURLUseCase | 147 | CopyURLUC *usecase.CopyURLUseCase | ||
| 129 | ShortcutsUC *usecase.SearchShortcutsUseCase | 148 | ShortcutsUC *usecase.SearchShortcutsUseCase | ||
| 149 | + | AutocompleteUC *usecase.AutocompleteUseCase | |||
| 130 | DefaultSearch string | 150 | DefaultSearch string | ||
| 131 | InitialBehavior string | 151 | InitialBehavior string | ||
| 132 | UIScale float64 // UI scale for favicon sizing | 152 | UIScale float64 // UI scale for favicon sizing | ||
| @@ -154,6 +174,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 154 | faviconAdapter: cfg.FaviconAdapter, | 174 | faviconAdapter: cfg.FaviconAdapter, | ||
| 155 | copyURLUC: cfg.CopyURLUC, | 175 | copyURLUC: cfg.CopyURLUC, | ||
| 156 | shortcutsUC: cfg.ShortcutsUC, | 176 | shortcutsUC: cfg.ShortcutsUC, | ||
| 177 | + | autocompleteUC: cfg.AutocompleteUC, | |||
| 157 | defaultSearch: cfg.DefaultSearch, | 178 | defaultSearch: cfg.DefaultSearch, | ||
| 158 | initialBehavior: cfg.InitialBehavior, | 179 | initialBehavior: cfg.InitialBehavior, | ||
| 159 | onToast: cfg.OnToast, | 180 | onToast: cfg.OnToast, | ||
| @@ -185,6 +206,86 @@ func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) { | |||||
| 185 | o.mu.Lock() | 206 | o.mu.Lock() | ||
| 186 | defer o.mu.Unlock() | 207 | defer o.mu.Unlock() | ||
| 187 | o.parentOverlay = overlay | 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 | // WidgetAsLayout returns the omnibox's outer widget as a layout.Widget. | 291 | // WidgetAsLayout returns the omnibox's outer widget as a layout.Widget. | ||
| @@ -389,6 +490,31 @@ func (o *Omnibox) initEntry() error { | |||||
| 389 | 490 | ||||
| 390 | placeholder := "Search history or enter URL… (! lists bangs)" | 491 | placeholder := "Search history or enter URL… (! lists bangs)" | ||
| 391 | o.entry.SetPlaceholderText(&placeholder) | 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 | return nil | 518 | return nil | ||
| 393 | } | 519 | } | ||
| 394 | 520 | ||||
| @@ -425,6 +551,48 @@ func (o *Omnibox) initList() error { | |||||
| 425 | } | 551 | } | ||
| 426 | o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb) | 552 | o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb) | ||
| 427 | o.listBox.ConnectRowSelected(&rowSelectedCb) | 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 | return nil | 596 | return nil | ||
| 429 | } | 597 | } | ||
| 430 | 598 | ||||
| @@ -447,7 +615,7 @@ func (o *Omnibox) assembleWidgets() { | |||||
| 447 | 615 | ||||
| 448 | o.scrolledWin.SetChild(&o.listBox.Widget) | 616 | o.scrolledWin.SetChild(&o.listBox.Widget) | ||
| 449 | o.mainBox.Append(&o.headerBox.Widget) | 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 | o.mainBox.Append(&o.scrolledWin.Widget) | 619 | o.mainBox.Append(&o.scrolledWin.Widget) | ||
| 452 | o.outerBox.Append(&o.mainBox.Widget) | 620 | o.outerBox.Append(&o.mainBox.Widget) | ||
| 453 | } | 621 | } | ||
| @@ -493,6 +661,20 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO | |||||
| 493 | o.entry.AddController(&focusController.EventController) | 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 | // handleKeyPress processes keyboard events. | 678 | // handleKeyPress processes keyboard events. | ||
| 497 | // Returns true if the event was handled. | 679 | // Returns true if the event was handled. | ||
| 498 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | 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 | case uint(gdk.KEY_Up): | 704 | case uint(gdk.KEY_Up): | ||
| 523 | o.selectPrevious() | 705 | o.selectPrevious() | ||
| 706 | + | o.updateGhostFromSelection() | |||
| 524 | return true | 707 | return true | ||
| 525 | 708 | ||||
| 526 | case uint(gdk.KEY_Down): | 709 | case uint(gdk.KEY_Down): | ||
| 527 | o.selectNext() | 710 | o.selectNext() | ||
| 711 | + | o.updateGhostFromSelection() | |||
| 528 | return true | 712 | return true | ||
| 529 | 713 | ||||
| 530 | case uint(gdk.KEY_Tab): | 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 | o.toggleViewMode() | 720 | o.toggleViewMode() | ||
| 532 | return true | 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 | case uint(gdk.KEY_space): | 731 | case uint(gdk.KEY_space): | ||
| 535 | // Space toggles favorite only if user has navigated with arrow keys | 732 | // Space toggles favorite only if user has navigated with arrow keys | ||
| 536 | - | // Otherwise, let space pass through to entry for typing | 733 | + | if o.hasUserNavigated() { |
| 537 | - | o.mu.RLock() | |||
| 538 | - | navigated := o.hasNavigated | |||
| 539 | - | o.mu.RUnlock() | |||
| 540 | - | if navigated { | |||
| 541 | o.toggleFavorite() | 734 | o.toggleFavorite() | ||
| 542 | return true | 735 | return true | ||
| 543 | } | 736 | } | ||
| @@ -545,10 +738,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 545 | 738 | ||||
| 546 | case uint(gdk.KEY_y): | 739 | case uint(gdk.KEY_y): | ||
| 547 | // 'y' yanks (copies) the selected URL to clipboard when navigating | 740 | // 'y' yanks (copies) the selected URL to clipboard when navigating | ||
| 548 | - | o.mu.RLock() | 741 | + | if o.hasUserNavigated() { |
| 549 | - | navigated := o.hasNavigated | |||
| 550 | - | o.mu.RUnlock() | |||
| 551 | - | if navigated { | |||
| 552 | o.yankSelectedURL() | 742 | o.yankSelectedURL() | ||
| 553 | return true | 743 | return true | ||
| 554 | } | 744 | } | ||
| @@ -599,11 +789,24 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool | |||||
| 599 | 789 | ||||
| 600 | // onEntryChanged handles text input changes with debouncing. | 790 | // onEntryChanged handles text input changes with debouncing. | ||
| 601 | func (o *Omnibox) onEntryChanged() { | 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 | // Reset navigation state when user types - space should type, not toggle favorite | 800 | // Reset navigation state when user types - space should type, not toggle favorite | ||
| 603 | o.mu.Lock() | 801 | o.mu.Lock() | ||
| 604 | o.hasNavigated = false | 802 | o.hasNavigated = false | ||
| 803 | + | // Update realInput with what user actually typed | |||
| 804 | + | o.realInput = o.entry.GetText() | |||
| 605 | o.mu.Unlock() | 805 | o.mu.Unlock() | ||
| 606 | 806 | ||||
| 807 | + | // Clear ghost text when user types (it will be recalculated after search) | |||
| 808 | + | o.clearGhostText() | |||
| 809 | + | ||||
| 607 | o.debounceMu.Lock() | 810 | o.debounceMu.Lock() | ||
| 608 | if o.debounceTimer != nil { | 811 | if o.debounceTimer != nil { | ||
| 609 | o.debounceTimer.Stop() | 812 | o.debounceTimer.Stop() | ||
| @@ -614,6 +817,252 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 614 | o.debounceMu.Unlock() | 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 | // performSearch executes the search based on current view mode and query. | 1066 | // performSearch executes the search based on current view mode and query. | ||
| 618 | func (o *Omnibox) performSearch() { | 1067 | func (o *Omnibox) performSearch() { | ||
| 619 | o.mu.RLock() | 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 | 1159 | + | // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input |
| 711 | - | o.idleAddUpdateSuggestions(suggestions) | 1160 | + | o.idleAddUpdateSuggestions(suggestions, query) |
| 712 | }() | 1161 | }() | ||
| 713 | } | 1162 | } | ||
| 714 | 1163 | ||||
| @@ -725,7 +1174,7 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 725 | 1174 | ||||
| 726 | switch o.initialBehavior { | 1175 | switch o.initialBehavior { | ||
| 727 | case "none": | 1176 | case "none": | ||
| 728 | - | o.idleAddUpdateSuggestions(nil) | 1177 | + | o.idleAddUpdateSuggestions(nil, "") |
| 729 | return | 1178 | return | ||
| 730 | 1179 | ||||
| 731 | case "most_visited", "recent", "": | 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 | // updateSuggestions updates the list with history suggestions. | 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 | o.mu.Lock() | 1364 | o.mu.Lock() | ||
| 915 | o.bangMode = false | 1365 | o.bangMode = false | ||
| 916 | o.bangSuggestions = nil | 1366 | o.bangSuggestions = nil | ||
| @@ -927,14 +1377,19 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||||
| 927 | } | 1377 | } | ||
| 928 | o.resizeAndCenter(rowCount) | 1378 | o.resizeAndCenter(rowCount) | ||
| 929 | 1379 | ||||
| 930 | - | // Select first item if available | 1380 | + | // Select first item if available and update ghost text |
| 931 | if rowCount > 0 { | 1381 | if rowCount > 0 { | ||
| 932 | o.selectIndex(0) | 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 | // updateFavorites updates the list with favorites. | 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 | o.mu.Lock() | 1393 | o.mu.Lock() | ||
| 939 | o.bangMode = false | 1394 | o.bangMode = false | ||
| 940 | o.bangSuggestions = nil | 1395 | o.bangSuggestions = nil | ||
| @@ -951,9 +1406,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) { | |||||
| 951 | } | 1406 | } | ||
| 952 | o.resizeAndCenter(rowCount) | 1407 | o.resizeAndCenter(rowCount) | ||
| 953 | 1408 | ||||
| 954 | - | // Select first item if available | 1409 | + | // Select first item if available and update ghost text |
| 955 | if rowCount > 0 { | 1410 | if rowCount > 0 { | ||
| 956 | o.selectIndex(0) | 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 | var targetURL string | 1732 | var targetURL string | ||
| 1274 | 1733 | ||||
| 1275 | - | // Check if user typed a URL-like string (contains . and no spaces) | 1734 | + | // If user has selected a result, navigate to that result |
| 1276 | - | // If so, navigate to that URL directly instead of the selection | 1735 | + | // Otherwise, treat entry text as URL/search |
| 1277 | - | if o.looksLikeURL(entryText) { | 1736 | + | if idx >= 0 { |
| 1278 | - | targetURL = o.buildURL(entryText) | 1737 | + | if mode == ViewModeHistory { |
| 1279 | - | } else if idx < 0 { | 1738 | + | if idx < len(suggestions) { |
| 1280 | - | // No selection - use entry text as URL/search | 1739 | + | targetURL = suggestions[idx].URL |
| 1281 | - | targetURL = o.buildURL(entryText) | 1740 | + | } |
| 1282 | - | } else if mode == ViewModeHistory { | 1741 | + | } else { |
| 1283 | - | if idx < len(suggestions) { | 1742 | + | if idx < len(favorites) { |
| 1284 | - | targetURL = suggestions[idx].URL | 1743 | + | targetURL = favorites[idx].URL |
| 1744 | + | } | |||
| 1285 | } | 1745 | } | ||
| 1286 | } else { | 1746 | } else { | ||
| 1287 | - | if idx < len(favorites) { | 1747 | + | // No selection - use entry text as URL/search |
| 1288 | - | targetURL = favorites[idx].URL | 1748 | + | targetURL = o.buildURL(entryText) |
| 1289 | - | } | |||
| 1290 | } | 1749 | } | ||
| 1291 | 1750 | ||||
| 1292 | if targetURL == "" { | 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 | // toggleFavorite adds or removes the selected item from favorites. | 1762 | // toggleFavorite adds or removes the selected item from favorites. | ||
| 1310 | // In History mode: adds the selected item to favorites | 1763 | // In History mode: adds the selected item to favorites | ||
| 1311 | // In Favorites mode: removes the selected item from favorites | 1764 | // In Favorites mode: removes the selected item from favorites | ||
| @@ -1542,8 +1995,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 1542 | return | 1995 | return | ||
| 1543 | } | 1996 | } | ||
| 1544 | o.visible = true | 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 | o.mu.Unlock() | 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 | // Set initial query | 2011 | // Set initial query | ||
| 1548 | o.entry.SetText(query) | 2012 | o.entry.SetText(query) | ||
| 1549 | 2013 | ||||
| @@ -1604,9 +2068,15 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||||
| 1604 | 2068 | ||||
| 1605 | // Clear state | 2069 | // Clear state | ||
| 1606 | o.clearBangState() | 2070 | o.clearBangState() | ||
| 2071 | + | o.clearGhostText() | |||
| 1607 | o.entry.SetText("") | 2072 | o.entry.SetText("") | ||
| 1608 | o.listBox.RemoveAll() | 2073 | o.listBox.RemoveAll() | ||
| 1609 | 2074 | ||||
| 2075 | + | // Reset realInput | |||
| 2076 | + | o.mu.Lock() | |||
| 2077 | + | o.realInput = "" | |||
| 2078 | + | o.mu.Unlock() | |||
| 2079 | + | ||||
| 1610 | if o.onClose != nil { | 2080 | if o.onClose != nil { | ||
| 1611 | o.onClose() | 2081 | o.onClose() | ||
| 1612 | } | 2082 | } | ||
| @@ -1662,9 +2132,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |||||
| 1662 | } | 2132 | } | ||
| 1663 | 2133 | ||||
| 1664 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | 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 | var cb glib.SourceFunc = func(data uintptr) bool { | 2137 | var cb glib.SourceFunc = func(data uintptr) bool { | ||
| 1667 | - | o.updateSuggestions(suggestions) | 2138 | + | o.updateSuggestions(suggestions, query) |
| 1668 | return false // One-shot callback | 2139 | return false // One-shot callback | ||
| 1669 | } | 2140 | } | ||
| 1670 | glib.IdleAdd(&cb, 0) | 2141 | glib.IdleAdd(&cb, 0) | ||
| @@ -1686,9 +2157,10 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} { | |||||
| 1686 | } | 2157 | } | ||
| 1687 | 2158 | ||||
| 1688 | // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread. | 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 | var cb glib.SourceFunc = func(data uintptr) bool { | 2162 | var cb glib.SourceFunc = func(data uintptr) bool { | ||
| 1691 | - | o.updateFavorites(favorites) | 2163 | + | o.updateFavorites(favorites, query) |
| 1692 | return false // One-shot callback, return false to remove source | 2164 | return false // One-shot callback, return false to remove source | ||
| 1693 | } | 2165 | } | ||
| 1694 | glib.IdleAdd(&cb, 0) | 2166 | glib.IdleAdd(&cb, 0) | ||
| @@ -1716,6 +2188,8 @@ func (o *Omnibox) Destroy() { | |||||
| 1716 | 2188 | ||||
| 1717 | o.parentOverlay = nil | 2189 | o.parentOverlay = nil | ||
| 1718 | o.retainedCallbacks = nil | 2190 | o.retainedCallbacks = nil | ||
| 2191 | + | o.clickOutsideController = nil | |||
| 2192 | + | o.clickOutsideCb = nil | |||
| 1719 | 2193 | ||||
| 1720 | if o.outerBox != nil { | 2194 | if o.outerBox != nil { | ||
| 1721 | o.outerBox.Unparent() | 2195 | o.outerBox.Unparent() | ||
M internal/ui/coordinator/content.go +23 -0
| @@ -24,6 +24,12 @@ import ( | |||||
| 24 | const ( | 24 | const ( | ||
| 25 | aboutBlankURI = "about:blank" | 25 | aboutBlankURI = "about:blank" | ||
| 26 | logURLMaxLen = 80 | 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 | // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. | 35 | // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. | ||
| @@ -936,6 +942,23 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 936 | return | 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 | // Show the WebView now that content is being painted | 962 | // Show the WebView now that content is being painted | ||
| 940 | // (WebViews are hidden on creation to avoid white flash) | 963 | // (WebViews are hidden on creation to avoid white flash) | ||
| 941 | // Skip showing if this is about:blank but the pane is loading a different URL | 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 | Msg("registered global shortcut") | 94 | Msg("registered global shortcut") | ||
| 95 | 95 | ||||
| 96 | if cfg != nil { | 96 | if cfg != nil { | ||
| 97 | - | registerFromConfig := func(key string, action Action) { | 97 | + | // Map action names to action constants for global shortcuts |
| 98 | - | binding, ok := ParseKeyString(key) | 98 | + | actionMap := map[string]Action{ |
| 99 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 100 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 101 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 102 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions { | |||
| 106 | + | action, ok := actionMap[actionName] | |||
| 99 | if !ok { | 107 | if !ok { | ||
| 100 | - | log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut") | 108 | + | continue |
| 101 | - | return | 109 | + | } |
| 110 | + | for _, keyStr := range actionBinding.Keys { | |||
| 111 | + | binding, ok := ParseKeyString(keyStr) | |||
| 112 | + | if !ok { | |||
| 113 | + | log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut") | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 117 | + | log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut") | |||
| 102 | } | 118 | } | ||
| 103 | - | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 104 | - | log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut") | |||
| 105 | } | 119 | } | ||
| 106 | - | ||||
| 107 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelLeft, ActionConsumeOrExpelLeft) | |||
| 108 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelRight, ActionConsumeOrExpelRight) | |||
| 109 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelUp, ActionConsumeOrExpelUp) | |||
| 110 | - | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelDown, ActionConsumeOrExpelDown) | |||
| 111 | } | 120 | } | ||
| 112 | 121 | ||||
| 113 | // Attach to window | 122 | // Attach to window | ||
M internal/ui/input/keyboard.go +13 -0
| @@ -79,6 +79,19 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { | |||||
| 79 | h.onAction = fn | 79 | h.onAction = fn | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | // ReloadShortcuts rebuilds the shortcut set from the new config. | |||
| 83 | + | // This enables hot-reloading of keybindings without restarting. | |||
| 84 | + | func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) { | |||
| 85 | + | h.mu.Lock() | |||
| 86 | + | defer h.mu.Unlock() | |||
| 87 | + | ||||
| 88 | + | log := logging.FromContext(ctx) | |||
| 89 | + | log.Debug().Msg("reloading shortcuts from config") | |||
| 90 | + | ||||
| 91 | + | h.shortcuts = NewShortcutSet(ctx, cfg) | |||
| 92 | + | h.cfg = cfg | |||
| 93 | + | } | |||
| 94 | + | ||||
| 82 | // SetOnModeChange sets the callback for mode changes (for UI updates). | 95 | // SetOnModeChange sets the callback for mode changes (for UI updates). | ||
| 83 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | 96 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | ||
| 84 | h.modal.SetOnModeChange(fn) | 97 | h.modal.SetOnModeChange(fn) | ||
M internal/ui/input/shortcuts.go +19 -19
| @@ -325,27 +325,27 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |||||
| 325 | // This follows Zellij-style modal keyboard interface. | 325 | // This follows Zellij-style modal keyboard interface. | ||
| 326 | // | 326 | // | ||
| 327 | // However, these standard browser shortcuts ARE global: | 327 | // However, these standard browser shortcuts ARE global: | ||
| 328 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ClosePane); ok { | 328 | + | // Map action names to action constants |
| 329 | - | s.Global[binding] = ActionClosePane // Ctrl+W closes active pane | 329 | + | actionMap := map[string]Action{ |
| 330 | - | } | 330 | + | "close_pane": ActionClosePane, |
| 331 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.NextTab); ok { | 331 | + | "next_tab": ActionNextTab, |
| 332 | - | s.Global[binding] = ActionNextTab // Ctrl+Tab | 332 | + | "previous_tab": ActionPreviousTab, |
| 333 | - | } | 333 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, |
| 334 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | 334 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, |
| 335 | - | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | 335 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, |
| 336 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 336 | } | 337 | } | ||
| 337 | 338 | ||||
| 338 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelLeft); ok { | 339 | + | for actionName, actionBinding := range cfg.Shortcuts.Actions { |
| 339 | - | s.Global[binding] = ActionConsumeOrExpelLeft | 340 | + | action, ok := actionMap[actionName] |
| 340 | - | } | 341 | + | if !ok { |
| 341 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok { | 342 | + | continue |
| 342 | - | s.Global[binding] = ActionConsumeOrExpelRight | 343 | + | } |
| 343 | - | } | 344 | + | for _, keyStr := range actionBinding.Keys { |
| 344 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok { | 345 | + | if binding, ok := ParseKeyString(keyStr); ok { |
| 345 | - | s.Global[binding] = ActionConsumeOrExpelUp | 346 | + | s.Global[binding] = action |
| 346 | - | } | 347 | + | } |
| 347 | - | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok { | 348 | + | } |
| 348 | - | s.Global[binding] = ActionConsumeOrExpelDown | |||
| 349 | } | 349 | } | ||
| 350 | } | 350 | } | ||
| 351 | 351 | ||||
M internal/ui/theme/css.go +10 -0
| @@ -409,6 +409,16 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | |||||
| 409 | color: var(--accent); | 409 | color: var(--accent); | ||
| 410 | font-weight: 600; | 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 | import js from "@eslint/js"; | 1 | import js from "@eslint/js"; | ||
| 2 | + | import svelte from "eslint-plugin-svelte"; | |||
| 3 | + | import globals from "globals"; | |||
| 2 | import tseslint from "typescript-eslint"; | 4 | import tseslint from "typescript-eslint"; | ||
| 5 | + | import svelteConfig from "./svelte.config.js"; | |||
| 3 | 6 | ||||
| 4 | export default tseslint.config( | 7 | export default tseslint.config( | ||
| 5 | js.configs.recommended, | 8 | js.configs.recommended, | ||
| 6 | ...tseslint.configs.recommended, | 9 | ...tseslint.configs.recommended, | ||
| 10 | + | ...svelte.configs.recommended, | |||
| 11 | + | { | |||
| 12 | + | languageOptions: { | |||
| 13 | + | globals: { | |||
| 14 | + | ...globals.browser, | |||
| 15 | + | }, | |||
| 16 | + | }, | |||
| 17 | + | }, | |||
| 18 | + | { | |||
| 19 | + | files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], | |||
| 20 | + | languageOptions: { | |||
| 21 | + | parserOptions: { | |||
| 22 | + | extraFileExtensions: [".svelte"], | |||
| 23 | + | parser: tseslint.parser, | |||
| 24 | + | svelteConfig, | |||
| 25 | + | }, | |||
| 26 | + | }, | |||
| 27 | + | }, | |||
| 7 | { | 28 | { | ||
| 8 | files: ["**/*.{js,mjs,cjs,ts}"], | 29 | files: ["**/*.{js,mjs,cjs,ts}"], | ||
| 9 | languageOptions: { | 30 | languageOptions: { | ||
M webui/package-lock.json +308 -7 generated file: collapsed
M webui/package.json +2 -0
| @@ -27,6 +27,8 @@ | |||||
| 27 | "@typescript-eslint/parser": "^7.0.0", | 27 | "@typescript-eslint/parser": "^7.0.0", | ||
| 28 | "autoprefixer": "^10.4.20", | 28 | "autoprefixer": "^10.4.20", | ||
| 29 | "eslint": "^8.57.0", | 29 | "eslint": "^8.57.0", | ||
| 30 | + | "eslint-plugin-svelte": "^3.14.0", | |||
| 31 | + | "globals": "^17.1.0", | |||
| 30 | "postcss": "^8.4.47", | 32 | "postcss": "^8.4.47", | ||
| 31 | "svelte": "^5.0.0", | 33 | "svelte": "^5.0.0", | ||
| 32 | "svelte-awesome-color-picker": "^4.1.0", | 34 | "svelte-awesome-color-picker": "^4.1.0", | ||
A webui/src/lib/bridge.ts +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 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | ||
| 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | ||
| 4 | import { cn } from "$lib/utils.js"; | 4 | import { cn } from "$lib/utils.js"; | ||
| 5 | + | import type { Snippet } from "svelte"; | |||
| 6 | + | import type { MouseEventHandler } from "svelte/elements"; | |||
| 5 | 7 | ||||
| 6 | let { | 8 | let { | ||
| 7 | ref = $bindable(null), | 9 | ref = $bindable(null), | ||
| 8 | class: className, | 10 | class: className, | ||
| 11 | + | children, | |||
| 12 | + | onclick, | |||
| 9 | ...restProps | 13 | ...restProps | ||
| 10 | - | }: AlertDialogPrimitive.ActionProps = $props(); | 14 | + | }: AlertDialogPrimitive.ActionProps & { |
| 15 | + | children?: Snippet; | |||
| 16 | + | onclick?: MouseEventHandler<HTMLButtonElement>; | |||
| 17 | + | } = $props(); | |||
| 11 | </script> | 18 | </script> | ||
| 12 | 19 | ||||
| 13 | <AlertDialogPrimitive.Action | 20 | <AlertDialogPrimitive.Action | ||
| 14 | bind:ref | 21 | bind:ref | ||
| 15 | class={cn(buttonVariants(), className)} | 22 | class={cn(buttonVariants(), className)} | ||
| 23 | + | {onclick} | |||
| 16 | {...restProps} | 24 | {...restProps} | ||
| 17 | - | /> | 25 | + | > |
| 26 | + | {@render children?.()} | |||
| 27 | + | </AlertDialogPrimitive.Action> | |||
M webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +8 -2
| @@ -2,16 +2,22 @@ | |||||
| 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | 2 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | ||
| 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | 3 | import { buttonVariants } from "$lib/components/ui/button/index.js"; | ||
| 4 | import { cn } from "$lib/utils.js"; | 4 | import { cn } from "$lib/utils.js"; | ||
| 5 | + | import type { Snippet } from "svelte"; | |||
| 5 | 6 | ||||
| 6 | let { | 7 | let { | ||
| 7 | ref = $bindable(null), | 8 | ref = $bindable(null), | ||
| 8 | class: className, | 9 | class: className, | ||
| 10 | + | children, | |||
| 9 | ...restProps | 11 | ...restProps | ||
| 10 | - | }: AlertDialogPrimitive.CancelProps = $props(); | 12 | + | }: AlertDialogPrimitive.CancelProps & { |
| 13 | + | children?: Snippet; | |||
| 14 | + | } = $props(); | |||
| 11 | </script> | 15 | </script> | ||
| 12 | 16 | ||||
| 13 | <AlertDialogPrimitive.Cancel | 17 | <AlertDialogPrimitive.Cancel | ||
| 14 | bind:ref | 18 | bind:ref | ||
| 15 | class={cn(buttonVariants({ variant: "outline" }), className)} | 19 | class={cn(buttonVariants({ variant: "outline" }), className)} | ||
| 16 | {...restProps} | 20 | {...restProps} | ||
| 17 | - | /> | 21 | + | > |
| 22 | + | {@render children?.()} | |||
| 23 | + | </AlertDialogPrimitive.Cancel> | |||
M webui/src/lib/components/ui/color-picker/color-picker.svelte +3 -12
| @@ -21,17 +21,8 @@ | |||||
| 21 | 21 | ||||
| 22 | let open = $state(false); | 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 | function handleInput(event: { hex: string | null }) { | 24 | function handleInput(event: { hex: string | null }) { | ||
| 33 | if (event.hex) { | 25 | if (event.hex) { | ||
| 34 | - | hex = event.hex; | |||
| 35 | value = event.hex; | 26 | value = event.hex; | ||
| 36 | onValueChange?.(event.hex); | 27 | onValueChange?.(event.hex); | ||
| 37 | } | 28 | } | ||
| @@ -60,7 +51,7 @@ | |||||
| 60 | sideOffset={4} | 51 | sideOffset={4} | ||
| 61 | > | 52 | > | ||
| 62 | <ColorPicker | 53 | <ColorPicker | ||
| 63 | - | bind:hex | 54 | + | bind:hex={value} |
| 64 | onInput={handleInput} | 55 | onInput={handleInput} | ||
| 65 | isDialog={false} | 56 | isDialog={false} | ||
| 66 | /> | 57 | /> | ||
| @@ -68,10 +59,10 @@ | |||||
| 68 | <div class="mt-2 flex items-center gap-2"> | 59 | <div class="mt-2 flex items-center gap-2"> | ||
| 69 | <span | 60 | <span | ||
| 70 | class="block h-6 w-6 shrink-0 border border-border" | 61 | class="block h-6 w-6 shrink-0 border border-border" | ||
| 71 | - | style="background-color: {hex};" | 62 | + | style="background-color: {value};" |
| 72 | aria-hidden="true" | 63 | aria-hidden="true" | ||
| 73 | ></span> | 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 | </div> | 66 | </div> | ||
| 76 | </Popover.Content> | 67 | </Popover.Content> | ||
| 77 | </Popover.Portal> | 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 | import { ModeWatcher } from "mode-watcher"; | 3 | import { ModeWatcher } from "mode-watcher"; | ||
| 4 | import ConfigShell from "./config/ConfigShell.svelte"; | 4 | import ConfigShell from "./config/ConfigShell.svelte"; | ||
| 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | ||
| 6 | + | import KeybindingsTab from "./config/KeybindingsTab.svelte"; | |||
| 6 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | 7 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | ||
| 7 | import { Button } from "$lib/components/ui/button"; | 8 | import { Button } from "$lib/components/ui/button"; | ||
| 8 | import { ColorPicker } from "$lib/components/ui/color-picker"; | 9 | import { ColorPicker } from "$lib/components/ui/color-picker"; | ||
| @@ -13,6 +14,12 @@ | |||||
| 13 | import * as Tabs from "$lib/components/ui/tabs"; | 14 | import * as Tabs from "$lib/components/ui/tabs"; | ||
| 14 | import { FlaskConical, RefreshCw } from "@lucide/svelte"; | 15 | import { FlaskConical, RefreshCw } from "@lucide/svelte"; | ||
| 15 | 16 | ||||
| 17 | + | function getErrorMessage(e: unknown): string { | |||
| 18 | + | if (e instanceof Error) return e.message; | |||
| 19 | + | if (typeof e === "string") return e; | |||
| 20 | + | return "Unknown error"; | |||
| 21 | + | } | |||
| 22 | + | ||||
| 16 | type ColorPalette = { | 23 | type ColorPalette = { | ||
| 17 | background: string; | 24 | background: string; | ||
| 18 | surface: string; | 25 | surface: string; | ||
| @@ -118,8 +125,8 @@ | |||||
| 118 | const response = await fetch("/api/config"); | 125 | const response = await fetch("/api/config"); | ||
| 119 | if (!response.ok) throw new Error("Failed to fetch config"); | 126 | if (!response.ok) throw new Error("Failed to fetch config"); | ||
| 120 | config = (await response.json()) as ConfigDTO; | 127 | config = (await response.json()) as ConfigDTO; | ||
| 121 | - | } catch (e: any) { | 128 | + | } catch (e: unknown) { |
| 122 | - | loadError = e.message; | 129 | + | loadError = getErrorMessage(e); |
| 123 | console.error("[config] load failed", e); | 130 | console.error("[config] load failed", e); | ||
| 124 | } finally { | 131 | } finally { | ||
| 125 | loading = false; | 132 | loading = false; | ||
| @@ -139,14 +146,14 @@ | |||||
| 139 | const response = await fetch("/api/config/default"); | 146 | const response = await fetch("/api/config/default"); | ||
| 140 | if (!response.ok) throw new Error("Failed to fetch default config"); | 147 | if (!response.ok) throw new Error("Failed to fetch default config"); | ||
| 141 | config = (await response.json()) as ConfigDTO; | 148 | config = (await response.json()) as ConfigDTO; | ||
| 142 | - | } catch (e: any) { | 149 | + | } catch (e: unknown) { |
| 143 | console.error("[config] reset defaults failed", e); | 150 | console.error("[config] reset defaults failed", e); | ||
| 144 | } | 151 | } | ||
| 145 | resetDialogOpen = false; | 152 | resetDialogOpen = false; | ||
| 146 | } | 153 | } | ||
| 147 | 154 | ||||
| 148 | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | 155 | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | ||
| 149 | - | const bridge = (window as any).webkit?.messageHandlers?.dumber; | 156 | + | const bridge = window.webkit?.messageHandlers?.dumber; |
| 150 | if (bridge && typeof bridge.postMessage === "function") { | 157 | if (bridge && typeof bridge.postMessage === "function") { | ||
| 151 | return bridge; | 158 | return bridge; | ||
| 152 | } | 159 | } | ||
| @@ -154,7 +161,7 @@ | |||||
| 154 | } | 161 | } | ||
| 155 | 162 | ||||
| 156 | function getWebViewId(): number { | 163 | function getWebViewId(): number { | ||
| 157 | - | return (window as any).__dumber_webview_id || 0; | 164 | + | return window.__dumber_webview_id || 0; |
| 158 | } | 165 | } | ||
| 159 | 166 | ||||
| 160 | async function saveConfig() { | 167 | async function saveConfig() { | ||
| @@ -194,7 +201,7 @@ | |||||
| 194 | } | 201 | } | ||
| 195 | }; | 202 | }; | ||
| 196 | 203 | ||||
| 197 | - | (window as any).__dumber_config_saved = (resp?: unknown) => { | 204 | + | window.__dumber_config_saved = (resp?: unknown) => { |
| 198 | clearSaveTimeout(); | 205 | clearSaveTimeout(); | ||
| 199 | console.debug("[config] save success", resp); | 206 | console.debug("[config] save success", resp); | ||
| 200 | saveSuccess = true; | 207 | saveSuccess = true; | ||
| @@ -212,7 +219,7 @@ | |||||
| 212 | saveSuccess = false; | 219 | saveSuccess = false; | ||
| 213 | }, 3000); | 220 | }, 3000); | ||
| 214 | }; | 221 | }; | ||
| 215 | - | (window as any).__dumber_config_error = (msg: unknown) => { | 222 | + | window.__dumber_config_error = (msg: unknown) => { |
| 216 | clearSaveTimeout(); | 223 | clearSaveTimeout(); | ||
| 217 | console.error("[config] save error", msg); | 224 | console.error("[config] save error", msg); | ||
| 218 | saveError = typeof msg === "string" ? msg : "Failed to save config"; | 225 | saveError = typeof msg === "string" ? msg : "Failed to save config"; | ||
| @@ -227,14 +234,13 @@ | |||||
| 227 | webview_id: webviewId, | 234 | webview_id: webviewId, | ||
| 228 | payload, | 235 | payload, | ||
| 229 | }); | 236 | }); | ||
| 230 | - | console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length }); | |||
| 231 | } catch (postErr) { | 237 | } catch (postErr) { | ||
| 232 | console.error("[config] postMessage threw", postErr); | 238 | console.error("[config] postMessage threw", postErr); | ||
| 233 | throw postErr; | 239 | throw postErr; | ||
| 234 | } | 240 | } | ||
| 235 | - | } catch (e: any) { | 241 | + | } catch (e: unknown) { |
| 236 | console.error("[config] save exception", e); | 242 | console.error("[config] save exception", e); | ||
| 237 | - | saveError = e.message; | 243 | + | saveError = getErrorMessage(e); |
| 238 | saving = false; | 244 | saving = false; | ||
| 239 | } | 245 | } | ||
| 240 | } | 246 | } | ||
| @@ -246,8 +252,8 @@ | |||||
| 246 | 252 | ||||
| 247 | onMount(() => { | 253 | onMount(() => { | ||
| 248 | console.debug("[config] mount", { | 254 | console.debug("[config] mount", { | ||
| 249 | - | webviewId: (window as any).__dumber_webview_id, | 255 | + | webviewId: window.__dumber_webview_id, |
| 250 | - | hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber), | 256 | + | hasBridge: Boolean(window.webkit?.messageHandlers?.dumber), |
| 251 | }); | 257 | }); | ||
| 252 | 258 | ||||
| 253 | const onThemeChanged = (e: Event) => { | 259 | const onThemeChanged = (e: Event) => { | ||
| @@ -298,6 +304,7 @@ | |||||
| 298 | <Tabs.List class="bg-muted/60"> | 304 | <Tabs.List class="bg-muted/60"> | ||
| 299 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | 305 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | ||
| 300 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | 306 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | ||
| 307 | + | <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger> | |||
| 301 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | 308 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | ||
| 302 | </Tabs.List> | 309 | </Tabs.List> | ||
| 303 | </div> | 310 | </div> | ||
| @@ -340,7 +347,12 @@ | |||||
| 340 | </div> | 347 | </div> | ||
| 341 | <div class="space-y-2"> | 348 | <div class="space-y-2"> | ||
| 342 | <Label for="ui_scale">UI Scale</Label> | 349 | <Label for="ui_scale">UI Scale</Label> | ||
| 343 | - | <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} /> | 350 | + | <Input |
| 351 | + | id="ui_scale" | |||
| 352 | + | type="number" | |||
| 353 | + | step="0.1" | |||
| 354 | + | bind:value={config.default_ui_scale} | |||
| 355 | + | /> | |||
| 344 | </div> | 356 | </div> | ||
| 345 | </div> | 357 | </div> | ||
| 346 | 358 | ||||
| @@ -424,6 +436,10 @@ | |||||
| 424 | </Card.Root> | 436 | </Card.Root> | ||
| 425 | </Tabs.Content> | 437 | </Tabs.Content> | ||
| 426 | 438 | ||||
| 439 | + | <Tabs.Content value="keybindings"> | |||
| 440 | + | <KeybindingsTab /> | |||
| 441 | + | </Tabs.Content> | |||
| 442 | + | ||||
| 427 | <Tabs.Content value="performance"> | 443 | <Tabs.Content value="performance"> | ||
| 428 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | 444 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | ||
| 429 | <Card.Header> | 445 | <Card.Header> | ||
| @@ -614,35 +630,37 @@ | |||||
| 614 | <span>Theme x{themeEvents}</span> | 630 | <span>Theme x{themeEvents}</span> | ||
| 615 | {/if} | 631 | {/if} | ||
| 616 | </div> | 632 | </div> | ||
| 617 | - | <div class="flex items-center gap-3"> | 633 | + | {#if activeTab !== "keybindings"} |
| 618 | - | <AlertDialog.Root bind:open={resetDialogOpen}> | 634 | + | <div class="flex items-center gap-3"> |
| 619 | - | <AlertDialog.Trigger disabled={saving}> | 635 | + | <AlertDialog.Root bind:open={resetDialogOpen}> |
| 620 | - | {#snippet child({ props })} | 636 | + | <AlertDialog.Trigger disabled={saving}> |
| 621 | - | <Button variant="outline" {...props} type="button"> | 637 | + | {#snippet child({ props })} |
| 622 | - | Reset Defaults | 638 | + | <Button variant="outline" {...props} type="button"> |
| 623 | - | </Button> | 639 | + | Reset Defaults |
| 624 | - | {/snippet} | 640 | + | </Button> |
| 625 | - | </AlertDialog.Trigger> | 641 | + | {/snippet} |
| 626 | - | <AlertDialog.Content> | 642 | + | </AlertDialog.Trigger> |
| 627 | - | <AlertDialog.Header> | 643 | + | <AlertDialog.Content> |
| 628 | - | <AlertDialog.Title>Reset to defaults?</AlertDialog.Title> | 644 | + | <AlertDialog.Header> |
| 629 | - | <AlertDialog.Description> | 645 | + | <AlertDialog.Title>Reset to defaults?</AlertDialog.Title> |
| 630 | - | This will reset all settings on this page to their default values. | 646 | + | <AlertDialog.Description> |
| 631 | - | You will still need to click Save to persist the changes. | 647 | + | This will reset all settings on this page to their default values. |
| 632 | - | </AlertDialog.Description> | 648 | + | You will still need to click Save to persist the changes. |
| 633 | - | </AlertDialog.Header> | 649 | + | </AlertDialog.Description> |
| 634 | - | <AlertDialog.Footer> | 650 | + | </AlertDialog.Header> |
| 635 | - | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | 651 | + | <AlertDialog.Footer> |
| 636 | - | <AlertDialog.Action onclick={doResetToDefaults}> | 652 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> |
| 637 | - | Reset Defaults | 653 | + | <AlertDialog.Action onclick={doResetToDefaults}> |
| 638 | - | </AlertDialog.Action> | 654 | + | Reset Defaults |
| 639 | - | </AlertDialog.Footer> | 655 | + | </AlertDialog.Action> |
| 640 | - | </AlertDialog.Content> | 656 | + | </AlertDialog.Footer> |
| 641 | - | </AlertDialog.Root> | 657 | + | </AlertDialog.Content> |
| 642 | - | <Button onclick={saveConfig} disabled={saving} type="button"> | 658 | + | </AlertDialog.Root> |
| 643 | - | {saving ? "Saving…" : "Save"} | 659 | + | <Button onclick={saveConfig} disabled={saving} type="button"> |
| 644 | - | </Button> | 660 | + | {saving ? "Saving…" : "Save"} |
| 645 | - | </div> | 661 | + | </Button> |
| 662 | + | </div> | |||
| 663 | + | {/if} | |||
| 646 | </div> | 664 | </div> | ||
| 647 | 665 | ||||
| 648 | <!-- Save error display --> | 666 | <!-- Save error display --> | ||
M webui/src/pages/ErrorPage.svelte +11 -2
| @@ -70,7 +70,11 @@ | |||||
| 70 | const handler = (event: MessageEvent) => { | 70 | const handler = (event: MessageEvent) => { | ||
| 71 | if (event.data?.requestId === requestId) { | 71 | if (event.data?.requestId === requestId) { | ||
| 72 | window.removeEventListener('message', handler); | 72 | window.removeEventListener('message', handler); | ||
| 73 | - | event.data.success ? resolve(event.data.data) : reject(new Error(event.data.error || 'Unknown error')); | 73 | + | if (event.data.success) { |
| 74 | + | resolve(event.data.data); | |||
| 75 | + | } else { | |||
| 76 | + | reject(new Error(event.data.error || 'Unknown error')); | |||
| 77 | + | } | |||
| 74 | } | 78 | } | ||
| 75 | }; | 79 | }; | ||
| 76 | window.addEventListener('message', handler); | 80 | window.addEventListener('message', handler); | ||
| @@ -110,7 +114,11 @@ | |||||
| 110 | } | 114 | } | ||
| 111 | 115 | ||||
| 112 | function retry() { | 116 | function retry() { | ||
| 113 | - | targetUrl ? window.location.href = targetUrl : window.location.reload(); | 117 | + | if (targetUrl) { |
| 118 | + | window.location.href = targetUrl; | |||
| 119 | + | } else { | |||
| 120 | + | window.location.reload(); | |||
| 121 | + | } | |||
| 114 | } | 122 | } | ||
| 115 | 123 | ||||
| 116 | function goBack() { | 124 | function goBack() { | ||
| @@ -144,6 +152,7 @@ | |||||
| 144 | <Card.Header class="text-center"> | 152 | <Card.Header class="text-center"> | ||
| 145 | <div class="flex justify-center mb-4"> | 153 | <div class="flex justify-center mb-4"> | ||
| 146 | <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | 154 | <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | ||
| 155 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths --> | |||
| 147 | {@html icons[config.icon] || icons.error} | 156 | {@html icons[config.icon] || icons.error} | ||
| 148 | </svg> | 157 | </svg> | ||
| 149 | </div> | 158 | </div> | ||
M webui/src/pages/config/ConfigShell.svelte +3 -4
| @@ -9,14 +9,12 @@ | |||||
| 9 | let { children }: Props = $props(); | 9 | let { children }: Props = $props(); | ||
| 10 | 10 | ||||
| 11 | function navigateWithViewTransition(url: string) { | 11 | function navigateWithViewTransition(url: string) { | ||
| 12 | - | const doc = document as any; | 12 | + | if (typeof document.startViewTransition !== 'function') { |
| 13 | - | ||||
| 14 | - | if (typeof doc?.startViewTransition !== 'function') { | |||
| 15 | window.location.href = url; | 13 | window.location.href = url; | ||
| 16 | return; | 14 | return; | ||
| 17 | } | 15 | } | ||
| 18 | 16 | ||||
| 19 | - | const transition = doc.startViewTransition(() => { | 17 | + | const transition = document.startViewTransition(() => { |
| 20 | document.documentElement.dataset.vt = 'leaving'; | 18 | document.documentElement.dataset.vt = 'leaving'; | ||
| 21 | }); | 19 | }); | ||
| 22 | 20 | ||||
| @@ -34,6 +32,7 @@ | |||||
| 34 | <svelte:head> | 32 | <svelte:head> | ||
| 35 | <title>dumb://config</title> | 33 | <title>dumb://config</title> | ||
| 36 | <meta name="description" content="Dumber Browser - Settings" /> | 34 | <meta name="description" content="Dumber Browser - Settings" /> | ||
| 35 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization --> | |||
| 37 | {@html `<style> | 36 | {@html `<style> | ||
| 38 | html, body { margin: 0; padding: 0; } | 37 | html, body { margin: 0; padding: 0; } | ||
| 39 | html { background: var(--background, #0a0a0a); } | 38 | html { background: var(--background, #0a0a0a); } | ||
A webui/src/pages/config/KeyCaptureModal.svelte +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 | // Convert data to Frappe Charts format (ensure all 24 hours) | 20 | // Convert data to Frappe Charts format (ensure all 24 hours) | ||
| 21 | const chartData = $derived.by(() => { | 21 | const chartData = $derived.by(() => { | ||
| 22 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation | |||
| 22 | const visitsByHour = new Map<number, number>(); | 23 | const visitsByHour = new Map<number, number>(); | ||
| 23 | hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count)); | 24 | hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count)); | ||
| 24 | 25 | ||||
M webui/src/pages/homepage/favorites/FavoriteEditor.svelte +1 -1
| @@ -139,7 +139,7 @@ | |||||
| 139 | <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label> | 139 | <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label> | ||
| 140 | <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}> | 140 | <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}> | ||
| 141 | <option value={null}>— None —</option> | 141 | <option value={null}>— None —</option> | ||
| 142 | - | {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key} | 142 | + | {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key (key)} |
| 143 | <option value={key}>{key}</option> | 143 | <option value={key}>{key}</option> | ||
| 144 | {/each} | 144 | {/each} | ||
| 145 | </select> | 145 | </select> | ||
M webui/src/pages/homepage/messaging.ts +34 -27
| @@ -23,8 +23,12 @@ import { homepageState } from './state.svelte'; | |||||
| 23 | // REQUEST TRACKING | 23 | // REQUEST TRACKING | ||
| 24 | // ═══════════════════════════════════════════════════════════════════════════════ | 24 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 25 | 25 | ||||
| 26 | - | interface PendingRequest<T = unknown> { | 26 | + | // Pending requests store resolvers for different response types. |
| 27 | - | resolve: (value: T) => void; | 27 | + | // The resolve function accepts `unknown` since requests with different |
| 28 | + | // response types share the same map. Type safety is maintained at the | |||
| 29 | + | // sendMessage<T> API level. | |||
| 30 | + | interface PendingRequest { | |||
| 31 | + | resolve: (value: unknown) => void; | |||
| 28 | reject: (error: Error) => void; | 32 | reject: (error: Error) => void; | ||
| 29 | timeout: ReturnType<typeof setTimeout>; | 33 | timeout: ReturnType<typeof setTimeout>; | ||
| 30 | } | 34 | } | ||
| @@ -38,8 +42,8 @@ let callbacksInitialized = false; | |||||
| 38 | // WEBKIT BRIDGE UTILITIES | 42 | // WEBKIT BRIDGE UTILITIES | ||
| 39 | // ═══════════════════════════════════════════════════════════════════════════════ | 43 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 40 | 44 | ||||
| 41 | - | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | 45 | + | function getWebKitBridge(): DumberBridge | null { |
| 42 | - | const bridge = (window as any).webkit?.messageHandlers?.dumber; | 46 | + | const bridge = window.webkit?.messageHandlers?.dumber; |
| 43 | if (bridge && typeof bridge.postMessage === 'function') { | 47 | if (bridge && typeof bridge.postMessage === 'function') { | ||
| 44 | return bridge; | 48 | return bridge; | ||
| 45 | } | 49 | } | ||
| @@ -47,7 +51,7 @@ function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |||||
| 47 | } | 51 | } | ||
| 48 | 52 | ||||
| 49 | function getWebViewId(): number { | 53 | function getWebViewId(): number { | ||
| 50 | - | return (window as any).__dumber_webview_id || 0; | 54 | + | return window.__dumber_webview_id ?? 0; |
| 51 | } | 55 | } | ||
| 52 | 56 | ||||
| 53 | function generateRequestId(type: string): string { | 57 | function generateRequestId(type: string): string { | ||
| @@ -62,9 +66,7 @@ function initializeCallbacks(): void { | |||||
| 62 | if (callbacksInitialized) return; | 66 | if (callbacksInitialized) return; | ||
| 63 | 67 | ||||
| 64 | // Generic response handler - accepts a single Response object from Go backend | 68 | // Generic response handler - accepts a single Response object from Go backend | ||
| 65 | - | (window as any).__dumber_homepage_response = ( | 69 | + | window.__dumber_homepage_response = (response) => { |
| 66 | - | response: { requestId: string; success: boolean; data?: unknown; error?: string } | |||
| 67 | - | ) => { | |||
| 68 | const request = pendingRequests.get(response.requestId); | 70 | const request = pendingRequests.get(response.requestId); | ||
| 69 | if (!request) return; | 71 | if (!request) return; | ||
| 70 | 72 | ||||
| @@ -74,61 +76,60 @@ function initializeCallbacks(): void { | |||||
| 74 | if (response.success) { | 76 | if (response.success) { | ||
| 75 | request.resolve(response.data); | 77 | request.resolve(response.data); | ||
| 76 | } else { | 78 | } else { | ||
| 77 | - | request.reject(new Error(response.error || 'Unknown error')); | 79 | + | request.reject(new Error(response.error ?? 'Unknown error')); |
| 78 | } | 80 | } | ||
| 79 | }; | 81 | }; | ||
| 80 | 82 | ||||
| 81 | // Legacy callbacks for backward compatibility | 83 | // Legacy callbacks for backward compatibility | ||
| 82 | // These can receive either raw data or a Response object | 84 | // These can receive either raw data or a Response object | ||
| 83 | - | (window as any).__dumber_history_timeline = (dataOrResponse: unknown) => { | 85 | + | window.__dumber_history_timeline = (dataOrResponse) => { |
| 84 | handleLegacyOrResponseCallback(dataOrResponse); | 86 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 85 | }; | 87 | }; | ||
| 86 | 88 | ||||
| 87 | - | (window as any).__dumber_folders = (dataOrResponse: unknown) => { | 89 | + | window.__dumber_folders = (dataOrResponse) => { |
| 88 | handleLegacyOrResponseCallback(dataOrResponse); | 90 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 89 | }; | 91 | }; | ||
| 90 | 92 | ||||
| 91 | - | (window as any).__dumber_tags = (dataOrResponse: unknown) => { | 93 | + | window.__dumber_tags = (dataOrResponse) => { |
| 92 | handleLegacyOrResponseCallback(dataOrResponse); | 94 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 93 | }; | 95 | }; | ||
| 94 | 96 | ||||
| 95 | - | (window as any).__dumber_favorites = (dataOrResponse: unknown) => { | 97 | + | window.__dumber_favorites = (dataOrResponse) => { |
| 96 | handleLegacyOrResponseCallback(dataOrResponse); | 98 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 97 | }; | 99 | }; | ||
| 98 | 100 | ||||
| 99 | - | (window as any).__dumber_analytics = (dataOrResponse: unknown) => { | 101 | + | window.__dumber_analytics = (dataOrResponse) => { |
| 100 | handleLegacyOrResponseCallback(dataOrResponse); | 102 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 101 | }; | 103 | }; | ||
| 102 | 104 | ||||
| 103 | - | (window as any).__dumber_domain_stats = (dataOrResponse: unknown) => { | 105 | + | window.__dumber_domain_stats = (dataOrResponse) => { |
| 104 | handleLegacyOrResponseCallback(dataOrResponse); | 106 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 105 | }; | 107 | }; | ||
| 106 | 108 | ||||
| 107 | - | (window as any).__dumber_history_search_results = (dataOrResponse: unknown) => { | 109 | + | window.__dumber_history_search_results = (dataOrResponse) => { |
| 108 | handleLegacyOrResponseCallback(dataOrResponse); | 110 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 109 | }; | 111 | }; | ||
| 110 | 112 | ||||
| 111 | - | (window as any).__dumber_history_deleted = (dataOrResponse: unknown) => { | 113 | + | window.__dumber_history_deleted = (dataOrResponse) => { |
| 112 | handleLegacyOrResponseCallback(dataOrResponse); | 114 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 113 | }; | 115 | }; | ||
| 114 | 116 | ||||
| 115 | - | (window as any).__dumber_history_cleared = (dataOrResponse: unknown) => { | 117 | + | window.__dumber_history_cleared = (dataOrResponse) => { |
| 116 | handleLegacyOrResponseCallback(dataOrResponse); | 118 | handleLegacyOrResponseCallback(dataOrResponse); | ||
| 117 | }; | 119 | }; | ||
| 118 | 120 | ||||
| 119 | - | (window as any).__dumber_error = (errorOrResponse: unknown) => { | 121 | + | window.__dumber_error = (errorOrResponse) => { |
| 120 | // Handle both formats: direct error string or Response object with error | 122 | // Handle both formats: direct error string or Response object with error | ||
| 121 | - | const response = errorOrResponse as { requestId?: string; error?: string } | string; | 123 | + | if (typeof errorOrResponse === 'string') { |
| 122 | - | if (typeof response === 'string') { | |||
| 123 | // Legacy format: direct error string | 124 | // Legacy format: direct error string | ||
| 124 | - | console.error('[messaging] Legacy error:', response); | 125 | + | console.error('[messaging] Legacy error:', errorOrResponse); |
| 125 | - | } else if (response && typeof response === 'object') { | 126 | + | } else if (errorOrResponse && typeof errorOrResponse === 'object') { |
| 126 | // Response object format | 127 | // Response object format | ||
| 127 | - | const request = pendingRequests.get(response.requestId || 'default'); | 128 | + | const request = pendingRequests.get(errorOrResponse.requestId ?? 'default'); |
| 128 | if (request) { | 129 | if (request) { | ||
| 129 | clearTimeout(request.timeout); | 130 | clearTimeout(request.timeout); | ||
| 130 | - | pendingRequests.delete(response.requestId || 'default'); | 131 | + | pendingRequests.delete(errorOrResponse.requestId ?? 'default'); |
| 131 | - | request.reject(new Error(response.error || 'Unknown error')); | 132 | + | request.reject(new Error(errorOrResponse.error ?? 'Unknown error')); |
| 132 | } | 133 | } | ||
| 133 | } | 134 | } | ||
| 134 | }; | 135 | }; | ||
| @@ -188,7 +189,13 @@ async function sendMessage<T = unknown>( | |||||
| 188 | reject(new Error(`Request timeout: ${type}`)); | 189 | reject(new Error(`Request timeout: ${type}`)); | ||
| 189 | }, REQUEST_TIMEOUT_MS); | 190 | }, REQUEST_TIMEOUT_MS); | ||
| 190 | 191 | ||||
| 191 | - | pendingRequests.set(requestId, { resolve: resolve as any, reject, timeout }); | 192 | + | // Cast resolve to accept unknown - type safety is maintained at API level |
| 193 | + | // since sendMessage<T> returns Promise<T> and callers expect typed responses | |||
| 194 | + | pendingRequests.set(requestId, { | |||
| 195 | + | resolve: resolve as (value: unknown) => void, | |||
| 196 | + | reject, | |||
| 197 | + | timeout, | |||
| 198 | + | }); | |||
| 192 | 199 | ||||
| 193 | try { | 200 | try { | ||
| 194 | bridge.postMessage({ | 201 | bridge.postMessage({ | ||
M webui/src/pages/homepage/panels/HistoryPanel.svelte +2 -0
| @@ -37,6 +37,7 @@ | |||||
| 37 | }); | 37 | }); | ||
| 38 | 38 | ||||
| 39 | // Recompute timeline from filtered entries | 39 | // Recompute timeline from filtered entries | ||
| 40 | + | /* eslint-disable svelte/prefer-svelte-reactivity -- temporary Map/Date for data grouping, not reactive state */ | |||
| 40 | const displayedTimeline = $derived(() => { | 41 | const displayedTimeline = $derived(() => { | ||
| 41 | const entries = displayedEntries(); | 42 | const entries = displayedEntries(); | ||
| 42 | if (entries.length === 0) return []; | 43 | if (entries.length === 0) return []; | ||
| @@ -78,6 +79,7 @@ | |||||
| 78 | 79 | ||||
| 79 | return result.sort((a, b) => b.date.localeCompare(a.date)); | 80 | return result.sort((a, b) => b.date.localeCompare(a.date)); | ||
| 80 | }); | 81 | }); | ||
| 82 | + | /* eslint-enable svelte/prefer-svelte-reactivity */ | |||
| 81 | 83 | ||||
| 82 | const handleSelectEntry = (entry: HistoryEntry) => { | 84 | const handleSelectEntry = (entry: HistoryEntry) => { | ||
| 83 | navigateTo(entry.url); | 85 | navigateTo(entry.url); | ||
M webui/src/pages/homepage/shell/CommandPalette.svelte +1 -1
| @@ -165,7 +165,7 @@ | |||||
| 165 | 165 | ||||
| 166 | // Reset selection when query changes | 166 | // Reset selection when query changes | ||
| 167 | $effect(() => { | 167 | $effect(() => { | ||
| 168 | - | query; // Track query | 168 | + | void query; // Track query changes |
| 169 | selectedIndex = 0; | 169 | selectedIndex = 0; | ||
| 170 | }); | 170 | }); | ||
| 171 | 171 | ||||
M webui/src/pages/homepage/shell/HomepageShell.svelte +6 -9
| @@ -35,14 +35,12 @@ | |||||
| 35 | }; | 35 | }; | ||
| 36 | 36 | ||||
| 37 | function navigateWithViewTransition(url: string) { | 37 | function navigateWithViewTransition(url: string) { | ||
| 38 | - | const doc = document as any; | 38 | + | if (typeof document.startViewTransition !== 'function') { |
| 39 | - | ||||
| 40 | - | if (typeof doc?.startViewTransition !== 'function') { | |||
| 41 | window.location.href = url; | 39 | window.location.href = url; | ||
| 42 | return; | 40 | return; | ||
| 43 | } | 41 | } | ||
| 44 | 42 | ||||
| 45 | - | const transition = doc.startViewTransition(() => { | 43 | + | const transition = document.startViewTransition(() => { |
| 46 | document.documentElement.dataset.vt = 'leaving'; | 44 | document.documentElement.dataset.vt = 'leaving'; | ||
| 47 | }); | 45 | }); | ||
| 48 | 46 | ||||
| @@ -53,17 +51,15 @@ | |||||
| 53 | 51 | ||||
| 54 | const toggleTheme = () => { | 52 | const toggleTheme = () => { | ||
| 55 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | 53 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | ||
| 56 | - | const manager = (window as any).__dumber_color_scheme_manager as | 54 | + | const manager = window.__dumber_color_scheme_manager; |
| 57 | - | | { setUserPreference?: (theme: ThemeMode) => void } | |||
| 58 | - | | undefined; | |||
| 59 | 55 | ||||
| 60 | if (manager?.setUserPreference) { | 56 | if (manager?.setUserPreference) { | ||
| 61 | manager.setUserPreference(nextMode); | 57 | manager.setUserPreference(nextMode); | ||
| 62 | return; | 58 | return; | ||
| 63 | } | 59 | } | ||
| 64 | 60 | ||||
| 65 | - | if ((window as any).__dumber_setTheme) { | 61 | + | if (window.__dumber_setTheme) { |
| 66 | - | (window as any).__dumber_setTheme(nextMode); | 62 | + | window.__dumber_setTheme(nextMode); |
| 67 | localStorage.setItem('dumber.theme', nextMode); | 63 | localStorage.setItem('dumber.theme', nextMode); | ||
| 68 | return; | 64 | return; | ||
| 69 | } | 65 | } | ||
| @@ -109,6 +105,7 @@ | |||||
| 109 | <svelte:head> | 105 | <svelte:head> | ||
| 110 | <title>dumb://home</title> | 106 | <title>dumb://home</title> | ||
| 111 | <meta name="description" content="Dumber Browser - Homepage" /> | 107 | <meta name="description" content="Dumber Browser - Homepage" /> | ||
| 108 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization --> | |||
| 112 | {@html `<style> | 109 | {@html `<style> | ||
| 113 | html, body { margin: 0; padding: 0; } | 110 | html, body { margin: 0; padding: 0; } | ||
| 114 | html { background: var(--background, #0a0a0a); } | 111 | html { background: var(--background, #0a0a0a); } | ||
M webui/src/pages/homepage/shell/KeyboardHints.svelte +4 -4
| @@ -136,7 +136,7 @@ | |||||
| 136 | {#each navHints as hint (hint.description)} | 136 | {#each navHints as hint (hint.description)} | ||
| 137 | <div class="hint-row"> | 137 | <div class="hint-row"> | ||
| 138 | <div class="hint-keys"> | 138 | <div class="hint-keys"> | ||
| 139 | - | {#each hint.keys as key, i} | 139 | + | {#each hint.keys as key, i (key)} |
| 140 | {#if i > 0}<span class="key-sep">/</span>{/if} | 140 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 141 | <kbd>{key}</kbd> | 141 | <kbd>{key}</kbd> | ||
| 142 | {/each} | 142 | {/each} | ||
| @@ -157,7 +157,7 @@ | |||||
| 157 | {#each panelSwitchHints as hint (hint.description)} | 157 | {#each panelSwitchHints as hint (hint.description)} | ||
| 158 | <div class="hint-row"> | 158 | <div class="hint-row"> | ||
| 159 | <div class="hint-keys"> | 159 | <div class="hint-keys"> | ||
| 160 | - | {#each hint.keys as key, i} | 160 | + | {#each hint.keys as key, i (key)} |
| 161 | {#if i > 0}<span class="key-sep">/</span>{/if} | 161 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 162 | <kbd>{key}</kbd> | 162 | <kbd>{key}</kbd> | ||
| 163 | {/each} | 163 | {/each} | ||
| @@ -179,7 +179,7 @@ | |||||
| 179 | {#each panelHints as hint (hint.description)} | 179 | {#each panelHints as hint (hint.description)} | ||
| 180 | <div class="hint-row"> | 180 | <div class="hint-row"> | ||
| 181 | <div class="hint-keys"> | 181 | <div class="hint-keys"> | ||
| 182 | - | {#each hint.keys as key, i} | 182 | + | {#each hint.keys as key, i (key)} |
| 183 | {#if i > 0}<span class="key-sep">/</span>{/if} | 183 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 184 | <kbd>{key}</kbd> | 184 | <kbd>{key}</kbd> | ||
| 185 | {/each} | 185 | {/each} | ||
| @@ -201,7 +201,7 @@ | |||||
| 201 | {#each quickAccessHints as hint (hint.description)} | 201 | {#each quickAccessHints as hint (hint.description)} | ||
| 202 | <div class="hint-row"> | 202 | <div class="hint-row"> | ||
| 203 | <div class="hint-keys"> | 203 | <div class="hint-keys"> | ||
| 204 | - | {#each hint.keys as key, i} | 204 | + | {#each hint.keys as key, i (key)} |
| 205 | {#if i > 0}<span class="key-sep">/</span>{/if} | 205 | {#if i > 0}<span class="key-sep">/</span>{/if} | ||
| 206 | <kbd>{key}</kbd> | 206 | <kbd>{key}</kbd> | ||
| 207 | {/each} | 207 | {/each} | ||
M webui/src/pages/homepage/shell/StatusBar.svelte +2 -1
| @@ -38,7 +38,7 @@ | |||||
| 38 | label: 'entries', | 38 | label: 'entries', | ||
| 39 | filtered: false, | 39 | filtered: false, | ||
| 40 | }; | 40 | }; | ||
| 41 | - | case 'favorites': | 41 | + | case 'favorites': { |
| 42 | const filtered = homepageState.filteredFavorites; | 42 | const filtered = homepageState.filteredFavorites; | ||
| 43 | const total = homepageState.favorites.length; | 43 | const total = homepageState.favorites.length; | ||
| 44 | return { | 44 | return { | ||
| @@ -46,6 +46,7 @@ | |||||
| 46 | label: filtered.length === total ? 'items' : `of ${total}`, | 46 | label: filtered.length === total ? 'items' : `of ${total}`, | ||
| 47 | filtered: filtered.length !== total, | 47 | filtered: filtered.length !== total, | ||
| 48 | }; | 48 | }; | ||
| 49 | + | } | |||
| 49 | case 'analytics': | 50 | case 'analytics': | ||
| 50 | return { | 51 | return { | ||
| 51 | count: homepageState.domainStats.length, | 52 | count: homepageState.domainStats.length, | ||
M webui/src/pages/homepage/state.svelte.ts +2 -0
| @@ -71,6 +71,7 @@ let pendingKeyPrefix = $state<string | null>(null); | |||||
| 71 | // HELPER FUNCTIONS | 71 | // HELPER FUNCTIONS | ||
| 72 | // ═══════════════════════════════════════════════════════════════════════════════ | 72 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 73 | 73 | ||||
| 74 | + | /* eslint-disable svelte/prefer-svelte-reactivity -- helper function uses temporary Map/Date, not reactive state */ | |||
| 74 | function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | 75 | function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | ||
| 75 | const groups = new Map<string, HistoryEntry[]>(); | 76 | const groups = new Map<string, HistoryEntry[]>(); | ||
| 76 | const today = new Date(); | 77 | const today = new Date(); | ||
| @@ -109,6 +110,7 @@ function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] { | |||||
| 109 | 110 | ||||
| 110 | return result.sort((a, b) => b.date.localeCompare(a.date)); | 111 | return result.sort((a, b) => b.date.localeCompare(a.date)); | ||
| 111 | } | 112 | } | ||
| 113 | + | /* eslint-enable svelte/prefer-svelte-reactivity */ | |||
| 112 | 114 | ||||
| 113 | // ═══════════════════════════════════════════════════════════════════════════════ | 115 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 114 | // EXPORTED STORE | 116 | // EXPORTED STORE | ||
M webui/src/pages/homepage/types.ts +3 -1
| @@ -3,6 +3,8 @@ | |||||
| 3 | // Mirrors Go models from internal/db/models.go and related query files | 3 | // Mirrors Go models from internal/db/models.go and related query files | ||
| 4 | // ═══════════════════════════════════════════════════════════════════════════════ | 4 | // ═══════════════════════════════════════════════════════════════════════════════ | ||
| 5 | 5 | ||||
| 6 | + | import type { Component } from 'svelte'; | |||
| 7 | + | ||||
| 6 | // ───────────────────────────────────────────────────────────────────────────── | 8 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 7 | // History Types | 9 | // History Types | ||
| 8 | // ───────────────────────────────────────────────────────────────────────────── | 10 | // ───────────────────────────────────────────────────────────────────────────── | ||
| @@ -110,7 +112,7 @@ export interface CommandPaletteItem { | |||||
| 110 | id: string; | 112 | id: string; | ||
| 111 | label: string; | 113 | label: string; | ||
| 112 | description?: string; | 114 | description?: string; | ||
| 113 | - | icon?: import('svelte').Component | string; | 115 | + | icon?: Component | string; |
| 114 | action: () => void; | 116 | action: () => void; | ||
| 115 | shortcut?: string; | 117 | shortcut?: string; | ||
| 116 | } | 118 | } | ||
A webui/src/types/dumber.d.ts +150 -0
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Global type declarations for Dumber browser WebKit bridge | |||
| 3 | + | */ | |||
| 4 | + | ||||
| 5 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 6 | + | // WEBKIT BRIDGE TYPES | |||
| 7 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 8 | + | ||||
| 9 | + | interface DumberBridge { | |||
| 10 | + | postMessage: (message: unknown) => void; | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | interface DumberWebKit { | |||
| 14 | + | messageHandlers?: { | |||
| 15 | + | dumber?: DumberBridge; | |||
| 16 | + | }; | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 20 | + | // VIEW TRANSITION API (not yet in TypeScript DOM lib) | |||
| 21 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 22 | + | ||||
| 23 | + | interface ViewTransition { | |||
| 24 | + | finished: Promise<void>; | |||
| 25 | + | ready: Promise<void>; | |||
| 26 | + | updateCallbackDone: Promise<void>; | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 30 | + | // THEME TYPES | |||
| 31 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 32 | + | ||||
| 33 | + | type ThemeMode = 'light' | 'dark'; | |||
| 34 | + | ||||
| 35 | + | interface ColorSchemeManager { | |||
| 36 | + | setUserPreference?: (theme: ThemeMode) => void; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 40 | + | // HOMEPAGE MESSAGING TYPES | |||
| 41 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 42 | + | ||||
| 43 | + | /** Response from Go backend for homepage requests */ | |||
| 44 | + | interface HomepageResponse { | |||
| 45 | + | requestId: string; | |||
| 46 | + | success: boolean; | |||
| 47 | + | data?: unknown; | |||
| 48 | + | error?: string; | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | /** Error response format (can be string for legacy or object) */ | |||
| 52 | + | type HomepageErrorResponse = string | { requestId?: string; error?: string }; | |||
| 53 | + | ||||
| 54 | + | /** Callback type for homepage responses */ | |||
| 55 | + | type HomepageResponseCallback = (response: HomepageResponse) => void; | |||
| 56 | + | ||||
| 57 | + | /** Callback type for legacy data responses (can receive raw data or Response object) */ | |||
| 58 | + | type HomepageLegacyCallback = (dataOrResponse: unknown) => void; | |||
| 59 | + | ||||
| 60 | + | /** Callback type for error responses */ | |||
| 61 | + | type HomepageErrorCallback = (errorOrResponse: HomepageErrorResponse) => void; | |||
| 62 | + | ||||
| 63 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 64 | + | // GLOBAL DECLARATIONS | |||
| 65 | + | // ═══════════════════════════════════════════════════════════════════════════════ | |||
| 66 | + | ||||
| 67 | + | declare global { | |||
| 68 | + | // Re-export bridge type for use in other modules | |||
| 69 | + | type DumberBridge = { | |||
| 70 | + | postMessage: (message: unknown) => void; | |||
| 71 | + | }; | |||
| 72 | + | interface Window { | |||
| 73 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 74 | + | // Core WebKit Bridge | |||
| 75 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 76 | + | /** WebView ID assigned by the browser backend */ | |||
| 77 | + | __dumber_webview_id?: number; | |||
| 78 | + | /** WebKit message handlers for native bridge */ | |||
| 79 | + | webkit?: DumberWebKit; | |||
| 80 | + | ||||
| 81 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 82 | + | // Config Page Callbacks | |||
| 83 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 84 | + | /** Callback invoked when config save succeeds */ | |||
| 85 | + | __dumber_config_saved?: (resp?: unknown) => void; | |||
| 86 | + | /** Callback invoked when config save fails */ | |||
| 87 | + | __dumber_config_error?: (msg: unknown) => void; | |||
| 88 | + | ||||
| 89 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 90 | + | // Keybindings Callbacks | |||
| 91 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 92 | + | /** Callback invoked when keybindings are loaded */ | |||
| 93 | + | __dumber_keybindings_loaded?: (resp?: unknown) => void; | |||
| 94 | + | /** Callback invoked when keybindings load fails */ | |||
| 95 | + | __dumber_keybindings_error?: (msg: unknown) => void; | |||
| 96 | + | /** Callback invoked when a keybinding is set */ | |||
| 97 | + | __dumber_keybinding_set?: (resp?: unknown) => void; | |||
| 98 | + | /** Callback invoked when keybinding set fails */ | |||
| 99 | + | __dumber_keybinding_set_error?: (msg: unknown) => void; | |||
| 100 | + | /** Callback invoked when a keybinding is reset */ | |||
| 101 | + | __dumber_keybinding_reset?: (resp?: unknown) => void; | |||
| 102 | + | /** Callback invoked when keybinding reset fails */ | |||
| 103 | + | __dumber_keybinding_reset_error?: (msg: unknown) => void; | |||
| 104 | + | /** Callback invoked when all keybindings are reset */ | |||
| 105 | + | __dumber_keybindings_reset_all?: (resp?: unknown) => void; | |||
| 106 | + | /** Callback invoked when reset all keybindings fails */ | |||
| 107 | + | __dumber_keybindings_reset_all_error?: (msg: unknown) => void; | |||
| 108 | + | ||||
| 109 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 110 | + | // Homepage Callbacks | |||
| 111 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 112 | + | /** Generic response handler for homepage requests */ | |||
| 113 | + | __dumber_homepage_response?: HomepageResponseCallback; | |||
| 114 | + | /** History timeline data callback */ | |||
| 115 | + | __dumber_history_timeline?: HomepageLegacyCallback; | |||
| 116 | + | /** Folders data callback */ | |||
| 117 | + | __dumber_folders?: HomepageLegacyCallback; | |||
| 118 | + | /** Tags data callback */ | |||
| 119 | + | __dumber_tags?: HomepageLegacyCallback; | |||
| 120 | + | /** Favorites data callback */ | |||
| 121 | + | __dumber_favorites?: HomepageLegacyCallback; | |||
| 122 | + | /** Analytics data callback */ | |||
| 123 | + | __dumber_analytics?: HomepageLegacyCallback; | |||
| 124 | + | /** Domain stats data callback */ | |||
| 125 | + | __dumber_domain_stats?: HomepageLegacyCallback; | |||
| 126 | + | /** History search results callback */ | |||
| 127 | + | __dumber_history_search_results?: HomepageLegacyCallback; | |||
| 128 | + | /** History deleted callback */ | |||
| 129 | + | __dumber_history_deleted?: HomepageLegacyCallback; | |||
| 130 | + | /** History cleared callback */ | |||
| 131 | + | __dumber_history_cleared?: HomepageLegacyCallback; | |||
| 132 | + | /** Error callback for homepage requests */ | |||
| 133 | + | __dumber_error?: HomepageErrorCallback; | |||
| 134 | + | ||||
| 135 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 136 | + | // Theme Management | |||
| 137 | + | // ───────────────────────────────────────────────────────────────────────── | |||
| 138 | + | /** Color scheme manager for theme switching */ | |||
| 139 | + | __dumber_color_scheme_manager?: ColorSchemeManager; | |||
| 140 | + | /** Legacy theme setter */ | |||
| 141 | + | __dumber_setTheme?: (theme: ThemeMode) => void; | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | interface Document { | |||
| 145 | + | /** View Transition API */ | |||
| 146 | + | startViewTransition?: (callback: () => void | Promise<void>) => ViewTransition; | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | export {}; | |||