detached ae3a77a

Merge pull request #143 from bnema/next

Release v0.26.0
commit
ae3a77aff8c6f890b3f01b421900df4c3854cb83
author
bnema · · 2026-01-24 16:42 UTC
committer
GitHub · · 2026-01-24 16:42 UTC
merge of
c4c037a + 8769977 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: 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 ### Fixed26 ### 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-0841 ## [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 ## Appearance144 ## 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 alpha239 # 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 Shortcuts309 ### 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 _c1037 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 _c1562 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://" + input89 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 URL117 // 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 *Config20+ config *Config
21- viper *viper.Viper21+ viper *viper.Viper
22- mu sync.RWMutex22+ mu sync.RWMutex
23- callbacks []func(*Config)23+ callbacks []func(*Config)
24- watching bool24+ 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 nil138 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 &configCopy260 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 preserving278+ 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.OnConfigChange296+ // 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 err304 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 err325 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 config334+ // 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 nil341 return nil
321 }342 }
322 343
323-// detectAndSetFonts detects available system fonts and sets the best available344+// 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 return355 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 apply378 // 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 -> newKey380 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 back423+ // 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 values652 // 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 line421+ 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] = action432 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 line440+ 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] = action451 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 line459+ 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 line462+ 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] = action472 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 line483+// 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 line484+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 line485+ 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 line486+ 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 opened494 // 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 line587+ 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] = action598 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 shortcuts448+ // 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 // Popups456 // 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 config21+ 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 return47 return
25 }48 }
26 49
27- // Notify callbacks50+ log.Debug().Msg("reloading config from external change")
28- m.mu.RLock()51+ if err := m.reload(); err != nil {
29- config := m.config52+ 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 = true59 m.watching = true
40 return nil60 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 each58 // 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{}, nil62 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 results73 // 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] = true106 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, nil128 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 NEAR144 // 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 FTS5147 // Remove characters that have special meaning in FTS5
127 var result strings.Builder148 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 DESC57 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 :many75 -- name: SearchHistoryFTSTitle :many
61 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at76 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
62 FROM history_fts fts77 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, nil529 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 :exec597 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) error76 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
76 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error77 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
77 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error78 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 string23+ downloadPath string
24- eventHandler port.DownloadEventHandler24+ eventHandler port.DownloadEventHandler
25- mu sync.RWMutex25+ 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.downloadPath58 downloadPath := h.downloadPath
49 eventHandler := h.eventHandler59 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 false74 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 synchronously113 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 = path173 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 base185+ 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 clean195+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 err37 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 nil61 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 return181 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 return199 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 return231 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 handle558+ 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 false563 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 false572+ 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 false578+ return false
571- }579+ }
572 580
573- mouseButton := navAction.GetMouseButton()581+ if !shouldForceDownload(responseDecision) {
574- modifiers := navAction.GetModifiers()582+ return false
575- isMiddleClick := mouseButton == 2583+ }
576- isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
577 584
578- if !isMiddleClick && !isCtrlClick {585+ policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr)
579- return false586+ 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 false592+ 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 false600+ 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 true634+ 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 false683 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 return1950 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 := newCfg1955 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 false1962 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 = 15028+ debounceDelayMs = 50
24 endBoxSpacing = 629 endBoxSpacing = 6
25 )30 )
26 31
@@ -67,6 +72,8 @@ type Omnibox struct {
67 zoomLabel *gtk.Label72 zoomLabel *gtk.Label
68 bangBadge *gtk.Label73 bangBadge *gtk.Label
69 entry *gtk.SearchEntry74 entry *gtk.SearchEntry
75+ entryOverlay *gtk.Overlay // Overlay for ghost text
76+ ghostLabel *gtk.Label // Ghost text completion label
70 scrolledWin *gtk.ScrolledWindow77 scrolledWin *gtk.ScrolledWindow
71 listBox *gtk.ListBox78 listBox *gtk.ListBox
72 79
@@ -85,12 +92,20 @@ type Omnibox struct {
85 detectedBang string92 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 // Dependencies102 // Dependencies
89 historyUC *usecase.SearchHistoryUseCase103 historyUC *usecase.SearchHistoryUseCase
90 favoritesUC *usecase.ManageFavoritesUseCase104 favoritesUC *usecase.ManageFavoritesUseCase
91 faviconAdapter *adapter.FaviconAdapter105 faviconAdapter *adapter.FaviconAdapter
92 copyURLUC *usecase.CopyURLUseCase106 copyURLUC *usecase.CopyURLUseCase
93 shortcutsUC *usecase.SearchShortcutsUseCase107 shortcutsUC *usecase.SearchShortcutsUseCase
108+ autocompleteUC *usecase.AutocompleteUseCase
94 defaultSearch string109 defaultSearch string
95 initialBehavior string110 initialBehavior string
96 ctx context.Context111 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.FaviconAdapter146 FaviconAdapter *adapter.FaviconAdapter
128 CopyURLUC *usecase.CopyURLUseCase147 CopyURLUC *usecase.CopyURLUseCase
129 ShortcutsUC *usecase.SearchShortcutsUseCase148 ShortcutsUC *usecase.SearchShortcutsUseCase
149+ AutocompleteUC *usecase.AutocompleteUseCase
130 DefaultSearch string150 DefaultSearch string
131 InitialBehavior string151 InitialBehavior string
132 UIScale float64 // UI scale for favicon sizing152 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 = overlay208 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 nil518 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 nil596 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 true707 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 true712 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 true721 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 keys732 // Space toggles favorite only if user has navigated with arrow keys
536- // Otherwise, let space pass through to entry for typing733+ 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 true735 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 navigating740 // '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 true743 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 favorite800 // Reset navigation state when user types - space should type, not toggle favorite
603 o.mu.Lock()801 o.mu.Lock()
604 o.hasNavigated = false802 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 thread1159+ // 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 return1178 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 = false1365 o.bangMode = false
916 o.bangSuggestions = nil1366 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 available1380+ // 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 = false1394 o.bangMode = false
940 o.bangSuggestions = nil1395 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 available1409+ // 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 string1732 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 selection1735+ // 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/search1739+ 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].URL1743+ 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].URL1748+ 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 favorites1763 // In History mode: adds the selected item to favorites
1311 // In Favorites mode: removes the selected item from favorites1764 // In Favorites mode: removes the selected item from favorites
@@ -1542,8 +1995,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
1542 return1995 return
1543 }1996 }
1544 o.visible = true1997 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 query2011 // 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 state2069 // 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 callback2139 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 source2164 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 = nil2189 o.parentOverlay = nil
1718 o.retainedCallbacks = nil2190 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 = 8026 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 return942 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 painted962 // 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 URL964 // 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- return109+ }
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 window122 // Attach to window
M internal/ui/input/keyboard.go +13 -0
@@ -79,6 +79,19 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
79 h.onAction = fn79 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 pane329+ 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+Tab332+ "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+Tab335+ "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] = ActionConsumeOrExpelLeft340+ action, ok := actionMap[actionName]
340- }341+ if !ok {
341- if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok {342+ continue
342- s.Global[binding] = ActionConsumeOrExpelRight343+ }
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] = ActionConsumeOrExpelUp346+ 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 ...restProps13 ...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.Action20 <AlertDialogPrimitive.Action
14 bind:ref21 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 ...restProps11 ...restProps
10- }: AlertDialogPrimitive.CancelProps = $props();12+ }: AlertDialogPrimitive.CancelProps & {
13+ children?: Snippet;
14+ } = $props();
11 </script>15 </script>
12 16
13 <AlertDialogPrimitive.Cancel17 <AlertDialogPrimitive.Cancel
14 bind:ref18 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 <ColorPicker53 <ColorPicker
63- bind:hex54+ 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 <span60 <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 Defaults638+ <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 Defaults653+ <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 TRACKING23 // 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 UTILITIES42 // 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 backend68 // 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 compatibility83 // Legacy callbacks for backward compatibility
82 // These can receive either raw data or a Response object84 // 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 error122 // 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 string124 // 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 format127 // 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 entries39 // 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 changes166 // Reset selection when query changes
167 $effect(() => {167 $effect(() => {
168- query; // Track query168+ 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 as54+ 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 FUNCTIONS71 // 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 STORE116 // 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 files3 // 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 Types9 // 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 {};

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close