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:
1010 interfaces:
1111 ConfigMigrator: {}
1212 ConfigSchemaProvider: {}
13+ ConfigTransformer: {}
1314 DatabaseProvider: {}
1415 DiffFormatter: {}
1516 SessionLogger: {}
@@ -27,6 +28,9 @@ packages:
2728 FocusedInputProvider: {}
2829 AccentPickerUI: {}
2930 FontDetector: {}
31+ KeybindingsProvider: {}
32+ KeybindingsSaver: {}
33+ DownloadResponse: {}
3034 github.com/bnema/dumber/internal/domain/repository:
3135 config:
3236 dir: '{{.InterfaceDir}}/mocks'
M CHANGELOG.md +30 -1
@@ -2,12 +2,41 @@
22
33 All notable changes to this project will be documented in this file.
44
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.
625
726 ### 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.
829 - **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.
930 - **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.
1031 - **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.
1140
1241 ## [0.25.0] - 2026-01-08
1342
M docs/CONFIG.md +29 -13
@@ -139,6 +139,7 @@ auto_open_on_new_pane = false
139139 | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory |
140140 | `logging.enable_file_log` | bool | `true` | - | Enable file logging |
141141 | `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 |
142143
143144 ## Appearance
144145
@@ -236,10 +237,10 @@ move-pane-to-tab = ["m"]
236237 move-pane-to-next-tab = ["M", "shift+m"]
237238
238239 # Consume-or-expel (niri-style) - very alpha
239-consume-or-expel-left = ["bracketleft", "["]
240-consume-or-expel-right = ["bracketright", "]"]
241-consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"]
242-consume-or-expel-down = ["shift+bracketright", "braceright", "}"]
240+consume-or-expel-left = ["["]
241+consume-or-expel-right = ["]"]
242+consume-or-expel-up = ["{"]
243+consume-or-expel-down = ["}"]
243244
244245 focus-right = ["shift+arrowright", "shift+l"]
245246 focus-left = ["shift+arrowleft", "shift+h"]
@@ -307,16 +308,31 @@ Notes:
307308
308309 ### Global Shortcuts
309310
310-| Key | Type | Default | Description |
311-|-----|------|---------|-------------|
312-| `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |
313-| `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |
314-| `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut |
315-| `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) |
316-| `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) |
317-| `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) |
318-| `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) |
311+Global shortcuts are configured under `workspace.shortcuts.actions` using the same `ActionBinding` structure as modal keybindings:
312+
313+| Action | Default Keys | Description |
314+|--------|--------------|-------------|
315+| `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) |
316+| `next_tab` | `ctrl+tab` | Switch to next tab |
317+| `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |
318+| `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |
319+| `consume_or_expel_right` | `alt+]` | Consume into right sibling stack, or expel right if stacked |
320+| `consume_or_expel_up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked |
321+| `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked |
319322
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+>
320336 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
321337 >
322338 > **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 {
2626 type DownloadEventHandler interface {
2727 OnDownloadEvent(ctx context.Context, event DownloadEvent)
2828 }
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
10371037 return _c
10381038 }
10391039
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+
10401107 // 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.
10411108 // The first argument is typically a *testing.T value.
10421109 func NewMockDatabaseProvider(t interface {
@@ -1495,6 +1562,425 @@ func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Co
14951562 return _c
14961563 }
14971564
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+
14981984 // 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.
14991985 // The first argument is typically a *testing.T value.
15001986 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 {
8282 }
8383
8484 // 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+ }
8588 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
8689 return "https://" + input
8790 }
@@ -112,6 +115,9 @@ func LooksLikeURL(input string) bool {
112115 }
113116
114117 // 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+ }
115121 return strings.Contains(input, ".") && !strings.Contains(input, " ")
116122 }
117123
M internal/domain/url/normalize_test.go +60 -0
@@ -62,6 +62,36 @@ func TestNormalize(t *testing.T) {
6262 input: "hello",
6363 want: "hello",
6464 },
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+ },
6595 }
6696
6797 for _, tt := range tests {
@@ -257,6 +287,36 @@ func TestLooksLikeURL(t *testing.T) {
257287 input: "hello",
258288 want: false,
259289 },
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+ },
260320 }
261321
262322 for _, tt := range tests {
M internal/infrastructure/config/defaults.go +53 -52
@@ -161,40 +161,40 @@ func DefaultConfig() *Config {
161161 PaneMode: PaneModeConfig{
162162 ActivationShortcut: defaultPaneActivationShortcut,
163163 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
164- Actions: map[string][]string{
165- "split-right": {"arrowright", "r"},
166- "split-left": {"arrowleft", "l"},
167- "split-up": {"arrowup", "u"},
168- "split-down": {"arrowdown", "d"},
169- "stack-pane": {"s"},
170- "close-pane": {"x"},
171- "move-pane-to-tab": {"m"},
172- "move-pane-to-next-tab": {"M", "shift+m"},
164+ Actions: map[string]ActionBinding{
165+ "split-right": {Keys: []string{"arrowright", "r"}, Desc: "Split pane to the right"},
166+ "split-left": {Keys: []string{"arrowleft", "l"}, Desc: "Split pane to the left"},
167+ "split-up": {Keys: []string{"arrowup", "u"}, Desc: "Split pane upward"},
168+ "split-down": {Keys: []string{"arrowdown", "d"}, Desc: "Split pane downward"},
169+ "stack-pane": {Keys: []string{"s"}, Desc: "Stack pane with sibling"},
170+ "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"},
171+ "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"},
172+ "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}, Desc: "Move pane to next tab"},
173173
174- "consume-or-expel-left": {"bracketleft", "["},
175- "consume-or-expel-right": {"bracketright", "]"},
176- "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"},
177- "consume-or-expel-down": {"shift+bracketright", "braceright", "}"},
174+ "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"},
175+ "consume-or-expel-right": {Keys: []string{"]"}, Desc: "Consume/expel pane right"},
176+ "consume-or-expel-up": {Keys: []string{"{"}, Desc: "Consume/expel pane up"},
177+ "consume-or-expel-down": {Keys: []string{"}"}, Desc: "Consume/expel pane down"},
178178
179- "focus-right": {"shift+arrowright", "shift+l"},
180- "focus-left": {"shift+arrowleft", "shift+h"},
181- "focus-up": {"shift+arrowup", "shift+k"},
182- "focus-down": {"shift+arrowdown", "shift+j"},
183- "confirm": {"enter"},
184- "cancel": {"escape"},
179+ "focus-right": {Keys: []string{"shift+arrowright", "shift+l"}, Desc: "Focus pane to the right"},
180+ "focus-left": {Keys: []string{"shift+arrowleft", "shift+h"}, Desc: "Focus pane to the left"},
181+ "focus-up": {Keys: []string{"shift+arrowup", "shift+k"}, Desc: "Focus pane above"},
182+ "focus-down": {Keys: []string{"shift+arrowdown", "shift+j"}, Desc: "Focus pane below"},
183+ "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
184+ "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
185185 },
186186 },
187187 TabMode: TabModeConfig{
188188 ActivationShortcut: defaultTabActivationShortcut,
189189 TimeoutMilliseconds: defaultTabTimeoutMilliseconds,
190- Actions: map[string][]string{
191- "new-tab": {"n", "c"},
192- "close-tab": {"x"},
193- "next-tab": {"l", "tab"},
194- "previous-tab": {"h", "shift+tab"},
195- "rename-tab": {"r"},
196- "confirm": {"enter"},
197- "cancel": {"escape"},
190+ Actions: map[string]ActionBinding{
191+ "new-tab": {Keys: []string{"n", "c"}, Desc: "Create new tab"},
192+ "close-tab": {Keys: []string{"x"}, Desc: "Close current tab"},
193+ "next-tab": {Keys: []string{"l", "tab"}, Desc: "Switch to next tab"},
194+ "previous-tab": {Keys: []string{"h", "shift+tab"}, Desc: "Switch to previous tab"},
195+ "rename-tab": {Keys: []string{"r"}, Desc: "Rename current tab"},
196+ "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
197+ "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
198198 },
199199 },
200200 ResizeMode: ResizeModeConfig{
@@ -202,30 +202,31 @@ func DefaultConfig() *Config {
202202 TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,
203203 StepPercent: defaultResizeStepPercent,
204204 MinPanePercent: defaultResizeMinPanePercent,
205- Actions: map[string][]string{
206- "resize-increase-left": {"h", "arrowleft"},
207- "resize-increase-down": {"j", "arrowdown"},
208- "resize-increase-up": {"k", "arrowup"},
209- "resize-increase-right": {"l", "arrowright"},
210- "resize-decrease-left": {"H"},
211- "resize-decrease-down": {"J"},
212- "resize-decrease-up": {"K"},
213- "resize-decrease-right": {"L"},
214- "resize-increase": {"+", "="},
215- "resize-decrease": {"-"},
216- "confirm": {"enter"},
217- "cancel": {"escape"},
205+ Actions: map[string]ActionBinding{
206+ "resize-increase-left": {Keys: []string{"h", "arrowleft"}, Desc: "Increase size leftward"},
207+ "resize-increase-down": {Keys: []string{"j", "arrowdown"}, Desc: "Increase size downward"},
208+ "resize-increase-up": {Keys: []string{"k", "arrowup"}, Desc: "Increase size upward"},
209+ "resize-increase-right": {Keys: []string{"l", "arrowright"}, Desc: "Increase size rightward"},
210+ "resize-decrease-left": {Keys: []string{"H"}, Desc: "Decrease size leftward"},
211+ "resize-decrease-down": {Keys: []string{"J"}, Desc: "Decrease size downward"},
212+ "resize-decrease-up": {Keys: []string{"K"}, Desc: "Decrease size upward"},
213+ "resize-decrease-right": {Keys: []string{"L"}, Desc: "Decrease size rightward"},
214+ "resize-increase": {Keys: []string{"+", "="}, Desc: "Increase pane size"},
215+ "resize-decrease": {Keys: []string{"-"}, Desc: "Decrease pane size"},
216+ "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
217+ "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
218218 },
219219 },
220220 Shortcuts: GlobalShortcutsConfig{
221- ClosePane: "ctrl+w",
222- NextTab: "ctrl+tab",
223- PreviousTab: "ctrl+shift+tab",
224-
225- ConsumeOrExpelLeft: "alt+bracketleft",
226- ConsumeOrExpelRight: "alt+bracketright",
227- ConsumeOrExpelUp: "alt+shift+bracketleft",
228- ConsumeOrExpelDown: "alt+shift+bracketright",
221+ Actions: map[string]ActionBinding{
222+ "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
223+ "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
224+ "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
225+ "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
226+ "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
227+ "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
228+ "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
229+ },
229230 },
230231 TabBarPosition: defaultTabBarPosition,
231232 HideTabBarWhenSingleTab: true,
@@ -269,10 +270,10 @@ func DefaultConfig() *Config {
269270 SessionMode: SessionModeConfig{
270271 ActivationShortcut: defaultSessionActivationShortcut,
271272 TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,
272- Actions: map[string][]string{
273- "session-manager": {"s", "w"},
274- "confirm": {"enter"},
275- "cancel": {"escape"},
273+ Actions: map[string]ActionBinding{
274+ "session-manager": {Keys: []string{"s", "w"}, Desc: "Open session manager"},
275+ "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
276+ "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
276277 },
277278 },
278279 },
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 (
1717
1818 // Manager handles configuration loading, watching, and reloading.
1919 type Manager struct {
20- config *Config
21- viper *viper.Viper
22- mu sync.RWMutex
23- callbacks []func(*Config)
24- watching bool
20+ config *Config
21+ viper *viper.Viper
22+ mu sync.RWMutex
23+ callbacks []func(*Config)
24+ watching bool
25+ skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
2526 }
2627
2728 // NewManager creates a new configuration manager.
@@ -130,9 +131,26 @@ func (m *Manager) readConfigFile() error {
130131 return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
131132 }
132133 }
134+
135+ // Transform legacy action bindings before unmarshaling
136+ m.transformLegacyActionBindings()
137+
133138 return nil
134139 }
135140
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+
136154 func (m *Manager) unmarshalConfig() (*Config, error) {
137155 config := &Config{}
138156 if err := m.viper.Unmarshal(config); err != nil {
@@ -242,7 +260,7 @@ func (m *Manager) Get() *Config {
242260 return &configCopy
243261 }
244262
245-// Save saves the provided configuration to disk and updates Viper.
263+// Save saves the provided configuration to disk with ordered sections.
246264 func (m *Manager) Save(cfg *Config) error {
247265 m.mu.Lock()
248266 defer m.mu.Unlock()
@@ -256,31 +274,32 @@ func (m *Manager) Save(cfg *Config) error {
256274 return fmt.Errorf("configuration validation failed: %w", err)
257275 }
258276
259- // Update Viper with the new values.
260- // Since we can't easily update Viper from a struct automatically while preserving
261- // all settings, we update the main keys we care about.
262- m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont)
263- m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont)
264- m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont)
265- m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize)
266- m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme)
267- m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette)
268- m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette)
269- m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom)
270- m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
271- m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
272-
273- // Performance profile (requires browser restart to take effect)
274- m.viper.Set("performance.profile", string(cfg.Performance.Profile))
275-
276- if err := m.viper.WriteConfig(); err != nil {
277+ // Write config with ordered sections (bypasses Viper's random map ordering)
278+ configFile := m.viper.ConfigFileUsed()
279+ if configFile == "" {
280+ var err error
281+ configFile, err = GetConfigFile()
282+ if err != nil {
283+ return fmt.Errorf("failed to determine config file path: %w", err)
284+ }
285+ }
286+
287+ // Set flag BEFORE writing to prevent fsnotify-triggered reload from
288+ // overwriting our in-memory config with stale viper cache
289+ m.skipNextReload = true
290+
291+ if err := WriteConfigOrdered(cfg, configFile); err != nil {
292+ m.skipNextReload = false // Reset on error
277293 return fmt.Errorf("failed to write config: %w", err)
278294 }
279295
280- // We don't update m.config here manually because viper.OnConfigChange
281- // will trigger reload() if Watch() is active.
282- // If Watch() is not active, we should call reload() manually.
296+ // Update in-memory config immediately (don't wait for file watcher)
297+ // This ensures subsequent Get() calls return the new values
298+ m.config = cfg
299+
300+ // Also reload from disk to sync Viper's internal state (if not watching)
283301 if !m.watching {
302+ m.skipNextReload = false // Clear flag since we're doing manual reload
284303 if err := m.reload(); err != nil {
285304 return err
286305 }
@@ -294,7 +313,7 @@ func (m *Manager) GetConfigFile() string {
294313 return m.viper.ConfigFileUsed()
295314 }
296315
297-// createDefaultConfig creates a default configuration file.
316+// createDefaultConfig creates a default configuration file with ordered sections.
298317 func (m *Manager) createDefaultConfig() error {
299318 configFile, err := GetConfigFile()
300319 if err != nil {
@@ -306,12 +325,14 @@ func (m *Manager) createDefaultConfig() error {
306325 return err
307326 }
308327
309- // Detect available fonts and override defaults before writing config.
310- m.detectAndSetFonts()
328+ // Start with default config
329+ cfg := DefaultConfig()
330+
331+ // Detect available fonts and override defaults
332+ m.detectAndSetFontsOnConfig(cfg)
311333
312- // Ensure TOML format and write config
313- m.viper.SetConfigType("toml")
314- if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
334+ // Write config with ordered sections
335+ if err := WriteConfigOrdered(cfg, configFile); err != nil {
315336 return fmt.Errorf("failed to write config file: %w", err)
316337 }
317338
@@ -320,9 +341,9 @@ func (m *Manager) createDefaultConfig() error {
320341 return nil
321342 }
322343
323-// detectAndSetFonts detects available system fonts and sets the best available
324-// fonts from the fallback chains. This runs only during first-run config creation.
325-func (m *Manager) detectAndSetFonts() {
344+// detectAndSetFontsOnConfig detects available system fonts and sets the best available
345+// fonts from the fallback chains directly on the Config struct.
346+func (*Manager) detectAndSetFontsOnConfig(cfg *Config) {
326347 // Create context with logger for debugging first-run font detection.
327348 logger := logging.NewFromEnv()
328349 ctx := logging.WithContext(context.Background(), logger)
@@ -334,13 +355,9 @@ func (m *Manager) detectAndSetFonts() {
334355 return
335356 }
336357
337- sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
338- serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
339- monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
340-
341- m.viper.Set("appearance.sans_font", sansFont)
342- m.viper.Set("appearance.serif_font", serifFont)
343- m.viper.Set("appearance.monospace_font", monoFont)
358+ cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
359+ cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
360+ cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
344361 }
345362
346363 // setDefaults sets default configuration values in Viper.
@@ -443,13 +460,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
443460 m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
444461 m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
445462 m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
446- m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
447- m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
448- m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)
449- m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft)
450- m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight)
451- m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp)
452- m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
463+ m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
453464 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
454465 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
455466 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) {
371371 return nil, fmt.Errorf("failed to read config file: %w", err)
372372 }
373373
374+ // Transform legacy action bindings (slice -> ActionBinding struct)
375+ transformer := NewLegacyConfigTransformer()
376+ transformer.TransformLegacyActions(rawConfig)
377+
374378 // Build sets of keys to remove and renames to apply
375379 keysToRemove := make(map[string]bool)
376380 renames := make(map[string]string) // oldKey -> newKey
@@ -416,8 +420,14 @@ func (m *Migrator) Migrate() ([]string, error) {
416420 return nil, fmt.Errorf("failed to merge config: %w", err)
417421 }
418422
419- // Write the merged config back
420- if err := userViper.WriteConfig(); err != nil {
423+ // Unmarshal to Config struct for ordered writing
424+ var cfg Config
425+ if err := userViper.Unmarshal(&cfg); err != nil {
426+ return nil, fmt.Errorf("failed to unmarshal merged config: %w", err)
427+ }
428+
429+ // Write with ordered sections
430+ if err := WriteConfigOrdered(&cfg, configFile); err != nil {
421431 return nil, fmt.Errorf("failed to write config file: %w", err)
422432 }
423433
@@ -635,6 +645,10 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an
635645 return nil, fmt.Errorf("failed to parse TOML: %w", err)
636646 }
637647
648+ // Transform legacy action bindings before flattening
649+ transformer := NewLegacyConfigTransformer()
650+ transformer.TransformLegacyActions(rawConfig)
651+
638652 // Flatten the map to dot-notation keys with values
639653 result := make(map[string]any)
640654 m.flattenMapWithValues(rawConfig, "", result)
M internal/infrastructure/config/schema.go +39 -29
@@ -410,19 +410,25 @@ type WorkspaceConfig struct {
410410 Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
411411 }
412412
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+
413419 // PaneModeConfig defines modal behavior for pane management.
414420 type PaneModeConfig struct {
415- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
416- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
417- Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
421+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
422+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
423+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
418424 }
419425
420426 // GetKeyBindings returns an inverted map for O(1) key→action lookup.
421427 // This is built from the action→keys structure in the config.
422428 func (p *PaneModeConfig) GetKeyBindings() map[string]string {
423429 keyToAction := make(map[string]string)
424- for action, keys := range p.Actions {
425- for _, key := range keys {
430+ for action, binding := range p.Actions {
431+ for _, key := range binding.Keys {
426432 keyToAction[key] = action
427433 }
428434 }
@@ -431,17 +437,17 @@ func (p *PaneModeConfig) GetKeyBindings() map[string]string {
431437
432438 // TabModeConfig defines modal behavior for tab management (Zellij-style).
433439 type TabModeConfig struct {
434- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
435- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
436- Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
440+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
441+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
442+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
437443 }
438444
439445 // GetKeyBindings returns an inverted map for O(1) key→action lookup.
440446 // This is built from the action→keys structure in the config.
441447 func (t *TabModeConfig) GetKeyBindings() map[string]string {
442448 keyToAction := make(map[string]string)
443- for action, keys := range t.Actions {
444- for _, key := range keys {
449+ for action, binding := range t.Actions {
450+ for _, key := range binding.Keys {
445451 keyToAction[key] = action
446452 }
447453 }
@@ -450,19 +456,19 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
450456
451457 // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
452458 type ResizeModeConfig struct {
453- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
454- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
455- StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
456- MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
457- Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
459+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
460+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
461+ StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
462+ MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
463+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
458464 }
459465
460466 // GetKeyBindings returns an inverted map for O(1) key→action lookup.
461467 // This is built from the action→keys structure in the config.
462468 func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
463469 keyToAction := make(map[string]string)
464- for action, keys := range r.Actions {
465- for _, key := range keys {
470+ for action, binding := range r.Actions {
471+ for _, key := range binding.Keys {
466472 keyToAction[key] = action
467473 }
468474 }
@@ -471,14 +477,18 @@ func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
471477
472478 // GlobalShortcutsConfig defines global shortcuts (always active, not modal).
473479 type GlobalShortcutsConfig struct {
474- ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`
475- NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`
476- PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`
480+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
481+}
477482
478- ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line
479- ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line
480- ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line
481- ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line
483+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
484+func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
485+ keyToAction := make(map[string]string)
486+ for action, binding := range g.Actions {
487+ for _, key := range binding.Keys {
488+ keyToAction[key] = action
489+ }
490+ }
491+ return keyToAction
482492 }
483493
484494 // PopupBehavior defines how popup windows should be opened
@@ -574,17 +584,17 @@ type SessionConfig struct {
574584
575585 // SessionModeConfig defines modal behavior for session management.
576586 type SessionModeConfig struct {
577- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
578- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
579- Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
587+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
588+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
589+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
580590 }
581591
582592 // GetKeyBindings returns an inverted map for O(1) key→action lookup.
583593 // This is built from the action→keys structure in the config.
584594 func (s *SessionModeConfig) GetKeyBindings() map[string]string {
585595 keyToAction := make(map[string]string)
586- for action, keys := range s.Actions {
587- for _, key := range keys {
596+ for action, binding := range s.Actions {
597+ for _, key := range binding.Keys {
588598 keyToAction[key] = action
589599 }
590600 }
M internal/infrastructure/config/schema_provider.go +5 -47
@@ -445,54 +445,12 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
445445 Description: "Key bindings for resize mode actions",
446446 Section: SectionWorkspace,
447447 },
448- // Global shortcuts
448+ // Global shortcuts - managed via dedicated keybindings UI
449449 {
450- Key: "workspace.shortcuts.close_pane",
451- Type: "string",
452- Default: defaults.Workspace.Shortcuts.ClosePane,
453- Description: "Global shortcut to close current pane",
454- Section: SectionWorkspace,
455- },
456- {
457- Key: "workspace.shortcuts.next_tab",
458- Type: "string",
459- Default: defaults.Workspace.Shortcuts.NextTab,
460- Description: "Global shortcut to switch to next tab",
461- Section: SectionWorkspace,
462- },
463- {
464- Key: "workspace.shortcuts.previous_tab",
465- Type: "string",
466- Default: defaults.Workspace.Shortcuts.PreviousTab,
467- Description: "Global shortcut to switch to previous tab",
468- Section: SectionWorkspace,
469- },
470- {
471- Key: "workspace.shortcuts.consume_or_expel_left",
472- Type: "string",
473- Default: defaults.Workspace.Shortcuts.ConsumeOrExpelLeft,
474- Description: "Consume into left sibling stack or expel left (alpha)",
475- Section: SectionWorkspace,
476- },
477- {
478- Key: "workspace.shortcuts.consume_or_expel_right",
479- Type: "string",
480- Default: defaults.Workspace.Shortcuts.ConsumeOrExpelRight,
481- Description: "Consume into right sibling stack or expel right (alpha)",
482- Section: SectionWorkspace,
483- },
484- {
485- Key: "workspace.shortcuts.consume_or_expel_up",
486- Type: "string",
487- Default: defaults.Workspace.Shortcuts.ConsumeOrExpelUp,
488- Description: "Consume into upper sibling stack or expel up (alpha)",
489- Section: SectionWorkspace,
490- },
491- {
492- Key: "workspace.shortcuts.consume_or_expel_down",
493- Type: "string",
494- Default: defaults.Workspace.Shortcuts.ConsumeOrExpelDown,
495- Description: "Consume into lower sibling stack or expel down (alpha)",
450+ Key: "workspace.shortcuts.actions",
451+ Type: "object",
452+ Default: "(see defaults)",
453+ Description: "Global keyboard shortcuts (use Keybindings tab to edit)",
496454 Section: SectionWorkspace,
497455 },
498456 // 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 {
166166 }
167167
168168 seenKeys := make(map[string]string)
169- for action, keys := range config.Workspace.PaneMode.Actions {
170- if len(keys) == 0 {
169+ for action, binding := range config.Workspace.PaneMode.Actions {
170+ if len(binding.Keys) == 0 {
171171 validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
172172 }
173- for _, key := range keys {
173+ for _, key := range binding.Keys {
174174 if existingAction, exists := seenKeys[key]; exists {
175175 validationErrors = append(validationErrors, fmt.Sprintf(
176176 "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
@@ -204,11 +204,11 @@ func validateTabMode(config *Config) []string {
204204 }
205205
206206 tabSeenKeys := make(map[string]string)
207- for action, keys := range config.Workspace.TabMode.Actions {
208- if len(keys) == 0 {
207+ for action, binding := range config.Workspace.TabMode.Actions {
208+ if len(binding.Keys) == 0 {
209209 validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
210210 }
211- for _, key := range keys {
211+ for _, key := range binding.Keys {
212212 if existingAction, exists := tabSeenKeys[key]; exists {
213213 validationErrors = append(validationErrors, fmt.Sprintf(
214214 "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
22
33 import (
44 "fmt"
5- "os"
65
6+ "github.com/bnema/dumber/internal/logging"
77 "github.com/fsnotify/fsnotify"
88 )
99
@@ -17,29 +17,62 @@ func (m *Manager) Watch() error {
1717 }
1818
1919 m.viper.WatchConfig()
20- m.viper.OnConfigChange(func(_ fsnotify.Event) {
21- // Reload config
22- if err := m.reload(); err != nil {
23- fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
20+ m.viper.OnConfigChange(func(e fsnotify.Event) {
21+ log := logging.NewFromEnv()
22+ log.Debug().Str("op", e.Op.String()).Str("file", e.Name).Msg("fsnotify config change detected")
23+
24+ // Acquire write lock before reload (reload modifies m.config)
25+ m.mu.Lock()
26+
27+ // Track whether notifyCallbacksLocked will handle the unlock
28+ willNotify := false
29+ defer func() {
30+ if !willNotify {
31+ m.mu.Unlock()
32+ }
33+ }()
34+
35+ // Skip reload if this was triggered by our own Save() - the in-memory
36+ // config is already correct and viper may have stale cached data
37+ if m.skipNextReload {
38+ log.Debug().Msg("skipping reload (triggered by own Save)")
39+ m.skipNextReload = false
40+ // Still need to sync viper's internal state with the file we just wrote
41+ if err := m.viper.ReadInConfig(); err != nil {
42+ log.Warn().Err(err).Msg("failed to sync viper config after Save")
43+ // Continue anyway - in-memory config is correct, just viper internal state is off
44+ }
45+ willNotify = true
46+ m.notifyCallbacksLocked()
2447 return
2548 }
2649
27- // Notify callbacks
28- m.mu.RLock()
29- config := m.config
30- callbacks := make([]func(*Config), len(m.callbacks))
31- copy(callbacks, m.callbacks)
32- m.mu.RUnlock()
33-
34- for _, callback := range callbacks {
35- callback(config)
50+ log.Debug().Msg("reloading config from external change")
51+ if err := m.reload(); err != nil {
52+ log.Warn().Err(err).Msg("failed to reload config")
53+ return // defer will unlock
3654 }
55+ willNotify = true
56+ m.notifyCallbacksLocked()
3757 })
3858
3959 m.watching = true
4060 return nil
4161 }
4262
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+
4376 // OnConfigChange registers a callback function to be called when config changes.
4477 func (m *Manager) OnConfigChange(callback func(*Config)) {
4578 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
5050 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
5151 log := logging.FromContext(ctx)
5252
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+
5358 // Split query into words and add prefix matching to each
5459 // This enables multi-word searches like "github issues" -> "github* issues*"
55- words := strings.Fields(query)
60+ words := strings.Fields(normalizedQuery)
5661 if len(words) == 0 {
5762 return []entity.HistoryMatch{}, nil
5863 }
@@ -66,13 +71,16 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
6671 ftsQuery := strings.Join(parts, " ")
6772
6873 // 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},
7078 Query: ftsQuery,
7179 Limit: int64(limit),
7280 })
7381 if urlErr != nil {
7482 log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")
75- urlRows = []sqlc.History{}
83+ urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}
7684 }
7785
7886 titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
@@ -97,7 +105,7 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
97105 if !seen[row.ID] {
98106 seen[row.ID] = true
99107 matches = append(matches, entity.HistoryMatch{
100- Entry: historyFromRow(row),
108+ Entry: historyFromDomainBoostRow(row),
101109 Score: 1.0,
102110 })
103111 }
@@ -120,8 +128,21 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
120128 return matches, nil
121129 }
122130
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+
123143 // sanitizeFTS5Word removes FTS5 special characters from a search word.
124144 // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
145+// Note: Periods are handled earlier by replacing with spaces (tokenizer separator)
125146 func sanitizeFTS5Word(word string) string {
126147 // Remove characters that have special meaning in FTS5
127148 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) {
484484 assert.True(t, urls["https://github.com"])
485485 assert.True(t, urls["https://gitlab.com"])
486486 }
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
5757 ORDER BY h.visit_count DESC, h.last_visited DESC
5858 LIMIT @limit;
5959
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+
6075 -- name: SearchHistoryFTSTitle :many
6176 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
6277 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
529529 return items, nil
530530 }
531531
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+
532597 const UpsertHistory = `-- name: UpsertHistory :exec
533598 INSERT INTO history (url, title, favicon_url)
534599 VALUES (?, ?, ?)
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
@@ -72,6 +72,7 @@ type Querier interface {
7272 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
7373 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
7474 SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
75+ SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
7576 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
7677 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
7778 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
M internal/infrastructure/webkit/download_handler.go +58 -44
@@ -4,11 +4,11 @@ import (
44 "context"
55 "fmt"
66 "os"
7- "path/filepath"
8- "strings"
97 "sync"
108
119 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ downloadutil "github.com/bnema/dumber/internal/domain/download"
1212 "github.com/bnema/dumber/internal/logging"
1313 "github.com/bnema/puregotk-webkit/webkit"
1414 )
@@ -20,16 +20,26 @@ const (
2020
2121 // DownloadHandler manages WebKit downloads and notifies the UI layer.
2222 type DownloadHandler struct {
23- downloadPath string
24- eventHandler port.DownloadEventHandler
25- mu sync.RWMutex
23+ downloadPath string
24+ eventHandler port.DownloadEventHandler
25+ prepareDownloadUC *usecase.PrepareDownloadUseCase
26+ mu sync.RWMutex
2627 }
2728
2829 // 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+ }
3039 return &DownloadHandler{
31- downloadPath: downloadPath,
32- eventHandler: handler,
40+ downloadPath: downloadPath,
41+ eventHandler: handler,
42+ prepareDownloadUC: prepareDownloadUC,
3343 }
3444 }
3545
@@ -47,6 +57,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
4757 h.mu.RLock()
4858 downloadPath := h.downloadPath
4959 eventHandler := h.eventHandler
60+ prepareDownloadUC := h.prepareDownloadUC
5061 h.mu.RUnlock()
5162
5263 // 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
6374 return false
6475 }
6576
66- // Sanitize filename to prevent path traversal attacks.
67- safeName := sanitizeFilename(suggestedFilename)
68- destPath := filepath.Join(downloadPath, safeName)
77+ // Wrap WebKit response as port.DownloadResponse
78+ var response port.DownloadResponse
79+ if resp := d.GetResponse(); resp != nil {
80+ response = &uriResponseAdapter{resp: resp}
81+ }
82+
83+ // Use PrepareDownloadUseCase to resolve filename
84+ output := prepareDownloadUC.Execute(ctx, usecase.PrepareDownloadInput{
85+ SuggestedFilename: suggestedFilename,
86+ Response: response,
87+ DownloadDir: downloadPath,
88+ })
6989
7090 log.Debug().
7191 Str("suggested", suggestedFilename).
72- Str("sanitized", safeName).
73- Str("destPath", destPath).
92+ Str("sanitized", output.Filename).
93+ Str("destPath", output.DestinationPath).
7494 Msg("setting download destination")
7595
7696 // WebKit expects an absolute file path for the destination.
77- d.SetDestination(destPath)
97+ d.SetDestination(output.DestinationPath)
7898
7999 // Notify: download started.
80100 if eventHandler != nil {
81101 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82102 Type: port.DownloadEventStarted,
83- Filename: safeName,
84- Destination: destPath,
103+ Filename: output.Filename,
104+ Destination: output.DestinationPath,
85105 })
86106 }
87107
88108 log.Info().
89- Str("filename", safeName).
90- Str("destination", destPath).
109+ Str("filename", output.Filename).
110+ Str("destination", output.DestinationPath).
91111 Msg("download started")
92112
93113 return false // false = we handled it synchronously
@@ -101,7 +121,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
101121 state.mu.Unlock()
102122
103123 dest := d.GetDestination()
104- filename := extractFilename(dest)
124+ filename := downloadutil.ExtractFilenameFromDestination(dest)
105125
106126 // Create error for the failed download.
107127 downloadErr := fmt.Errorf("download failed: %s", filename)
@@ -131,7 +151,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
131151 }
132152
133153 dest := d.GetDestination()
134- filename := extractFilename(dest)
154+ filename := downloadutil.ExtractFilenameFromDestination(dest)
135155
136156 if eventHandler != nil {
137157 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
@@ -153,34 +173,28 @@ func (h *DownloadHandler) SetDownloadPath(path string) {
153173 h.downloadPath = path
154174 }
155175
156-// extractFilename extracts the filename from a file:// URI or path.
157-func extractFilename(dest string) string {
158- // Remove file:// prefix if present.
159- path := strings.TrimPrefix(dest, "file://")
160- base := filepath.Base(path)
176+// uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
177+type uriResponseAdapter struct {
178+ resp *webkit.URIResponse
179+}
161180
162- // Handle edge cases consistently with sanitizeFilename.
163- if base == "." || base == "" {
164- return "download"
181+func (a *uriResponseAdapter) GetMimeType() string {
182+ if a.resp == nil {
183+ return ""
165184 }
166- return base
185+ return a.resp.GetMimeType()
167186 }
168187
169-// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
170-// It extracts only the base name and handles edge cases like "." or "..".
171-func sanitizeFilename(name string) string {
172- // Normalize Windows-style separators to forward slashes.
173- // filepath.Base only handles the OS-native separator, so on Linux
174- // backslashes would not be treated as path separators.
175- name = strings.ReplaceAll(name, "\\", "/")
176-
177- // Get only the base name (removes any directory components).
178- clean := filepath.Base(name)
179-
180- // If Base returns "." or ".." (edge cases), use fallback.
181- if clean == "." || clean == ".." || clean == "" {
182- return "download"
188+func (a *uriResponseAdapter) GetSuggestedFilename() string {
189+ if a.resp == nil {
190+ return ""
183191 }
192+ return a.resp.GetSuggestedFilename()
193+}
184194
185- return clean
195+func (a *uriResponseAdapter) GetUri() string {
196+ if a.resp == nil {
197+ return ""
198+ }
199+ return a.resp.GetUri()
186200 }
M internal/infrastructure/webkit/download_handler_test.go +18 -120
@@ -3,146 +3,44 @@ package webkit
33 import (
44 "testing"
55
6+ "github.com/bnema/dumber/internal/application/usecase"
67 "github.com/stretchr/testify/assert"
78 )
89
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-
12610 func TestNewDownloadHandler(t *testing.T) {
11+ prepareUC := usecase.NewPrepareDownloadUseCase(nil)
12+
12713 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)
12915
13016 assert.NotNil(t, handler)
13117 assert.Equal(t, "/tmp/downloads", handler.downloadPath)
13218 assert.Nil(t, handler.eventHandler)
19+ assert.NotNil(t, handler.prepareDownloadUC)
13320 })
13421
13522 t.Run("creates handler with custom path", func(t *testing.T) {
136- handler := NewDownloadHandler("/custom/path", nil)
23+ handler := NewDownloadHandler("/custom/path", nil, prepareUC)
13724
13825 assert.Equal(t, "/custom/path", handler.downloadPath)
13926 })
14027 }
14128
14229 func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143- handler := NewDownloadHandler("/initial/path", nil)
30+ prepareUC := usecase.NewPrepareDownloadUseCase(nil)
31+ handler := NewDownloadHandler("/initial/path", nil, prepareUC)
14432
14533 handler.SetDownloadPath("/new/path")
14634
14735 assert.Equal(t, "/new/path", handler.downloadPath)
14836 }
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
22
33 import (
44 "context"
5+ "fmt"
56
67 "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/application/usecase"
@@ -36,6 +37,15 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
3637 return err
3738 }
3839
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+
3949 // Clipboard handlers (for auto-copy on selection feature)
4050 if cfg.Clipboard != nil && cfg.ConfigGetter != nil {
4151 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)
5060
5161 return nil
5262 }
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
174174 func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
175175 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
176176
177+ log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
178+
177179 if valuePtr == 0 {
178180 log.Warn().Msg("received script message with nil value pointer")
179181 return
@@ -186,8 +188,14 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
186188 }
187189
188190 rawJSON := jscValue.ToJson(0)
191+ log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result")
189192 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")
191199 return
192200 }
193201
@@ -213,7 +221,13 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
213221
214222 entry, ok := r.getHandler(msg.Type)
215223 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()
217231 return
218232 }
219233
@@ -221,9 +235,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
221235 Str("type", msg.Type).
222236 Uint64("webview_id", msg.WebViewID).
223237 Int("payload_len", len(msg.Payload)).
224- Msg("received script message")
238+ Str("callback", entry.callback).
239+ Msg("received script message, dispatching to handler")
225240
226241 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")
227243 if err != nil {
228244 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
229245 if entry.errorCallback != "" {
M internal/infrastructure/webkit/webview.go +145 -41
@@ -3,6 +3,8 @@ package webkit
33 import (
44 "context"
55 "fmt"
6+ "net/url"
7+ "strings"
68 "sync"
79 "sync/atomic"
810 "time"
@@ -552,60 +554,156 @@ func (wv *WebView) connectProgressSignal() {
552554
553555 func (wv *WebView) connectDecidePolicySignal() {
554556 decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
555- if decisionType != webkit.PolicyDecisionTypeNavigationActionValue {
556- return false // Let WebKit handle
557- }
558-
559- navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
560- if navDecision == nil {
557+ switch decisionType {
558+ case webkit.PolicyDecisionTypeResponseValue:
559+ return wv.handleResponsePolicyDecision(decisionPtr)
560+ case webkit.PolicyDecisionTypeNavigationActionValue:
561+ return wv.handleNavigationPolicyDecision(decisionPtr)
562+ default:
561563 return false
562564 }
565+ }
566+ sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
567+ wv.signalIDs = append(wv.signalIDs, sigID)
568+}
563569
564- navAction := navDecision.GetNavigationAction()
565- if navAction == nil {
566- return false
567- }
570+// handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
571+func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
572+ responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr)
573+ if responseDecision == nil {
574+ return false
575+ }
568576
569- if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
570- return false
571- }
577+ if !responseDecision.IsMainFrameMainResource() {
578+ return false
579+ }
572580
573- mouseButton := navAction.GetMouseButton()
574- modifiers := navAction.GetModifiers()
575- isMiddleClick := mouseButton == 2
576- isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
581+ if !shouldForceDownload(responseDecision) {
582+ return false
583+ }
577584
578- if !isMiddleClick && !isCtrlClick {
579- return false
580- }
585+ policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr)
586+ if policyDecision == nil {
587+ return false
588+ }
581589
582- request := navAction.GetRequest()
583- if request == nil {
584- return false
585- }
590+ response := responseDecision.GetResponse()
591+ mimeType := ""
592+ uri := ""
593+ if response != nil {
594+ mimeType = response.GetMimeType()
595+ uri = response.GetUri()
596+ }
586597
587- linkURI := request.GetUri()
588- if linkURI == "" {
589- return false
590- }
598+ wv.logger.Debug().
599+ Str("mime_type", mimeType).
600+ Str("uri", uri).
601+ Msg("forcing download for response")
602+
603+ // Start a fresh download instead of converting the in-progress response.
604+ // Using policyDecision.Download() can fail due to race conditions where
605+ // the network connection is released before the download can use it.
606+ // By ignoring the policy and starting a new download, we avoid this issue.
607+ // We defer the download to the next idle cycle to let WebKit clean up
608+ // the ignored navigation first.
609+ if uri != "" {
610+ policyDecision.Ignore()
611+ downloadURI := uri
612+ inner := wv.inner
613+ cb := glib.SourceFunc(func(_ uintptr) bool {
614+ inner.DownloadUri(downloadURI)
615+ return false // Don't repeat
616+ })
617+ // Store callback reference to prevent GC before GTK calls it
618+ wv.mu.Lock()
619+ wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
620+ wv.mu.Unlock()
621+ glib.IdleAdd(&cb, 0)
622+ return true
623+ }
591624
592- wv.logger.Debug().
593- Str("uri", linkURI).
594- Uint("button", mouseButton).
595- Bool("ctrl", isCtrlClick).
596- Msg("middle-click/ctrl+click on link detected")
597-
598- if wv.OnLinkMiddleClick != nil {
599- if wv.OnLinkMiddleClick(linkURI) {
600- navDecision.Ignore()
601- return true
602- }
625+ // Fallback to old behavior if URI is not available
626+ policyDecision.Download()
627+ return true
628+}
629+
630+// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).
631+func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
632+ navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
633+ if navDecision == nil {
634+ return false
635+ }
636+
637+ navAction := navDecision.GetNavigationAction()
638+ if navAction == nil {
639+ return false
640+ }
641+
642+ if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643+ return false
644+ }
645+
646+ mouseButton := navAction.GetMouseButton()
647+ modifiers := navAction.GetModifiers()
648+ isMiddleClick := mouseButton == 2
649+ isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
650+
651+ if !isMiddleClick && !isCtrlClick {
652+ return false
653+ }
654+
655+ request := navAction.GetRequest()
656+ if request == nil {
657+ return false
658+ }
659+
660+ linkURI := request.GetUri()
661+ if linkURI == "" {
662+ return false
663+ }
664+
665+ wv.logger.Debug().
666+ Str("uri", linkURI).
667+ Uint("button", mouseButton).
668+ Bool("ctrl", isCtrlClick).
669+ Msg("middle-click/ctrl+click on link detected")
670+
671+ if wv.OnLinkMiddleClick != nil {
672+ if wv.OnLinkMiddleClick(linkURI) {
673+ navDecision.Ignore()
674+ return true
603675 }
676+ }
677+
678+ return false
679+}
604680
681+func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
682+ if responseDecision == nil {
605683 return false
606684 }
607- sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
608- wv.signalIDs = append(wv.signalIDs, sigID)
685+
686+ response := responseDecision.GetResponse()
687+ if response == nil {
688+ return false
689+ }
690+
691+ mimeType := strings.ToLower(response.GetMimeType())
692+ if strings.HasPrefix(mimeType, "application/pdf") {
693+ return true
694+ }
695+
696+ uri := response.GetUri()
697+ if uri == "" {
698+ return false
699+ }
700+
701+ parsed, err := url.Parse(uri)
702+ if err != nil {
703+ return strings.Contains(strings.ToLower(uri), ".pdf")
704+ }
705+
706+ return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf")
609707 }
610708
611709 func (wv *WebView) connectEnterFullscreenSignal() {
@@ -905,6 +1003,12 @@ func (wv *WebView) SetBackgroundColor(r, g, b, a float32) {
9051003 wv.inner.SetBackgroundColor(rgba)
9061004 }
9071005
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+
9081012 // Show makes the WebView widget visible.
9091013 // This should be called after the WebView is ready to be displayed.
9101014 func (wv *WebView) Show() {
M internal/ui/app.go +14 -2
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/domain/entity"
1414 "github.com/bnema/dumber/internal/infrastructure/config"
1515 "github.com/bnema/dumber/internal/infrastructure/desktop"
16+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
1617 "github.com/bnema/dumber/internal/infrastructure/filtering"
1718 "github.com/bnema/dumber/internal/infrastructure/snapshot"
1819 "github.com/bnema/dumber/internal/infrastructure/textinput"
@@ -495,8 +496,11 @@ func (a *App) initDownloadHandler(ctx context.Context) {
495496 // Create download event adapter to show toasts.
496497 eventAdapter := &downloadEventAdapter{app: a}
497498
499+ // Create use case for preparing download destinations with file deduplication.
500+ prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
501+
498502 // Create and wire the download handler.
499- handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
503+ handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
500504 a.deps.WebContext.SetDownloadHandler(ctx, handler)
501505
502506 log.Info().Str("path", downloadPath).Msg("download handler initialized")
@@ -590,6 +594,9 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
590594 }
591595 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
592596
597+ // Create autocomplete use case for inline suggestions
598+ autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
599+
593600 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
594601 a.omniboxCfg = component.OmniboxConfig{
595602 HistoryUC: a.deps.HistoryUC,
@@ -597,6 +604,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
597604 FaviconAdapter: a.faviconAdapter,
598605 CopyURLUC: a.deps.CopyURLUC,
599606 ShortcutsUC: shortcutsUC,
607+ AutocompleteUC: autocompleteUC,
600608 DefaultSearch: a.deps.Config.DefaultSearchEngine,
601609 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
602610 UIScale: a.deps.Config.DefaultUIScale,
@@ -1942,11 +1950,15 @@ func (a *App) initConfigWatcher(ctx context.Context) {
19421950 return
19431951 }
19441952
1945- // Only appearance is hot-reloaded for now.
1953+ // Hot-reload appearance and keybindings on config change.
19461954 a.configManager.OnConfigChange(func(newCfg *config.Config) {
19471955 cfgCopy := newCfg
19481956 cb := glib.SourceFunc(func(_ uintptr) bool {
19491957 a.applyAppearanceConfig(ctx, cfgCopy)
1958+ // Reload keyboard shortcuts
1959+ if a.keyboardHandler != nil {
1960+ a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy)
1961+ }
19501962 return false
19511963 })
19521964 glib.IdleAdd(&cb, 0)
M internal/ui/component/omnibox.go +517 -43
@@ -3,11 +3,14 @@ package component
33 import (
44 "context"
55 "fmt"
6+ "math"
67 "strings"
78 "sync"
89 "time"
10+ "unicode/utf8"
911
1012 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/autocomplete"
1114 "github.com/bnema/dumber/internal/domain/entity"
1215 "github.com/bnema/dumber/internal/domain/url"
1316 "github.com/bnema/dumber/internal/logging"
@@ -16,11 +19,13 @@ import (
1619 "github.com/bnema/dumber/internal/ui/layout"
1720 "github.com/jwijenbergh/puregotk/v4/gdk"
1821 "github.com/jwijenbergh/puregotk/v4/glib"
22+ "github.com/jwijenbergh/puregotk/v4/graphene"
1923 "github.com/jwijenbergh/puregotk/v4/gtk"
24+ "github.com/jwijenbergh/puregotk/v4/pango"
2025 )
2126
2227 const (
23- debounceDelayMs = 150
28+ debounceDelayMs = 50
2429 endBoxSpacing = 6
2530 )
2631
@@ -67,6 +72,8 @@ type Omnibox struct {
6772 zoomLabel *gtk.Label
6873 bangBadge *gtk.Label
6974 entry *gtk.SearchEntry
75+ entryOverlay *gtk.Overlay // Overlay for ghost text
76+ ghostLabel *gtk.Label // Ghost text completion label
7077 scrolledWin *gtk.ScrolledWindow
7178 listBox *gtk.ListBox
7279
@@ -85,12 +92,20 @@ type Omnibox struct {
8592 detectedBang string
8693 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
8794
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+
88102 // Dependencies
89103 historyUC *usecase.SearchHistoryUseCase
90104 favoritesUC *usecase.ManageFavoritesUseCase
91105 faviconAdapter *adapter.FaviconAdapter
92106 copyURLUC *usecase.CopyURLUseCase
93107 shortcutsUC *usecase.SearchShortcutsUseCase
108+ autocompleteUC *usecase.AutocompleteUseCase
94109 defaultSearch string
95110 initialBehavior string
96111 ctx context.Context
@@ -118,6 +133,10 @@ type Omnibox struct {
118133
119134 // Callback retention: keep GTK signal callbacks reachable by Go GC.
120135 retainedCallbacks []interface{}
136+
137+ // Click outside handler
138+ clickOutsideController *gtk.GestureClick
139+ clickOutsideCb func(gtk.GestureClick, int, float64, float64)
121140 }
122141
123142 // OmniboxConfig holds configuration for creating an Omnibox.
@@ -127,6 +146,7 @@ type OmniboxConfig struct {
127146 FaviconAdapter *adapter.FaviconAdapter
128147 CopyURLUC *usecase.CopyURLUseCase
129148 ShortcutsUC *usecase.SearchShortcutsUseCase
149+ AutocompleteUC *usecase.AutocompleteUseCase
130150 DefaultSearch string
131151 InitialBehavior string
132152 UIScale float64 // UI scale for favicon sizing
@@ -154,6 +174,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
154174 faviconAdapter: cfg.FaviconAdapter,
155175 copyURLUC: cfg.CopyURLUC,
156176 shortcutsUC: cfg.ShortcutsUC,
177+ autocompleteUC: cfg.AutocompleteUC,
157178 defaultSearch: cfg.DefaultSearch,
158179 initialBehavior: cfg.InitialBehavior,
159180 onToast: cfg.OnToast,
@@ -185,6 +206,86 @@ func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) {
185206 o.mu.Lock()
186207 defer o.mu.Unlock()
187208 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+ }
188289 }
189290
190291 // WidgetAsLayout returns the omnibox's outer widget as a layout.Widget.
@@ -389,6 +490,31 @@ func (o *Omnibox) initEntry() error {
389490
390491 placeholder := "Search history or enter URL… (! lists bangs)"
391492 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+
392518 return nil
393519 }
394520
@@ -425,6 +551,48 @@ func (o *Omnibox) initList() error {
425551 }
426552 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
427553 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+
428596 return nil
429597 }
430598
@@ -447,7 +615,7 @@ func (o *Omnibox) assembleWidgets() {
447615
448616 o.scrolledWin.SetChild(&o.listBox.Widget)
449617 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
451619 o.mainBox.Append(&o.scrolledWin.Widget)
452620 o.outerBox.Append(&o.mainBox.Widget)
453621 }
@@ -493,6 +661,20 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO
493661 o.entry.AddController(&focusController.EventController)
494662 }
495663
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+
496678 // handleKeyPress processes keyboard events.
497679 // Returns true if the event was handled.
498680 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
521703
522704 case uint(gdk.KEY_Up):
523705 o.selectPrevious()
706+ o.updateGhostFromSelection()
524707 return true
525708
526709 case uint(gdk.KEY_Down):
527710 o.selectNext()
711+ o.updateGhostFromSelection()
528712 return true
529713
530714 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+ }
531720 o.toggleViewMode()
532721 return true
533722
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+
534731 case uint(gdk.KEY_space):
535732 // Space toggles favorite only if user has navigated with arrow keys
536- // Otherwise, let space pass through to entry for typing
537- o.mu.RLock()
538- navigated := o.hasNavigated
539- o.mu.RUnlock()
540- if navigated {
733+ if o.hasUserNavigated() {
541734 o.toggleFavorite()
542735 return true
543736 }
@@ -545,10 +738,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
545738
546739 case uint(gdk.KEY_y):
547740 // 'y' yanks (copies) the selected URL to clipboard when navigating
548- o.mu.RLock()
549- navigated := o.hasNavigated
550- o.mu.RUnlock()
551- if navigated {
741+ if o.hasUserNavigated() {
552742 o.yankSelectedURL()
553743 return true
554744 }
@@ -599,11 +789,24 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
599789
600790 // onEntryChanged handles text input changes with debouncing.
601791 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+
602800 // Reset navigation state when user types - space should type, not toggle favorite
603801 o.mu.Lock()
604802 o.hasNavigated = false
803+ // Update realInput with what user actually typed
804+ o.realInput = o.entry.GetText()
605805 o.mu.Unlock()
606806
807+ // Clear ghost text when user types (it will be recalculated after search)
808+ o.clearGhostText()
809+
607810 o.debounceMu.Lock()
608811 if o.debounceTimer != nil {
609812 o.debounceTimer.Stop()
@@ -614,6 +817,252 @@ func (o *Omnibox) onEntryChanged() {
614817 o.debounceMu.Unlock()
615818 }
616819
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+
6171066 // performSearch executes the search based on current view mode and query.
6181067 func (o *Omnibox) performSearch() {
6191068 o.mu.RLock()
@@ -707,8 +1156,8 @@ func (o *Omnibox) performSearch() {
7071156 })
7081157 }
7091158
710- // Marshal back to GTK main thread
711- o.idleAddUpdateSuggestions(suggestions)
1159+ // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input
1160+ o.idleAddUpdateSuggestions(suggestions, query)
7121161 }()
7131162 }
7141163
@@ -725,7 +1174,7 @@ func (o *Omnibox) loadInitialHistory() {
7251174
7261175 switch o.initialBehavior {
7271176 case "none":
728- o.idleAddUpdateSuggestions(nil)
1177+ o.idleAddUpdateSuggestions(nil, "")
7291178 return
7301179
7311180 case "most_visited", "recent", "":
@@ -770,7 +1219,7 @@ func (o *Omnibox) loadInitialHistory() {
7701219 }
7711220 }
7721221
773- o.idleAddUpdateSuggestions(suggestions)
1222+ o.idleAddUpdateSuggestions(suggestions, "")
7741223 }()
7751224 }
7761225
@@ -808,7 +1257,7 @@ func (o *Omnibox) loadFavorites(query string) {
8081257 })
8091258 }
8101259
811- o.idleAddUpdateFavorites(favorites)
1260+ o.idleAddUpdateFavorites(favorites, query)
8121261 }()
8131262 }
8141263
@@ -910,7 +1359,8 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
9101359 }
9111360
9121361 // 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) {
9141364 o.mu.Lock()
9151365 o.bangMode = false
9161366 o.bangSuggestions = nil
@@ -927,14 +1377,19 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
9271377 }
9281378 o.resizeAndCenter(rowCount)
9291379
930- // Select first item if available
1380+ // Select first item if available and update ghost text
9311381 if rowCount > 0 {
9321382 o.selectIndex(0)
1383+ // Update ghost text from first suggestion
1384+ o.updateGhostFromSuggestion(query)
1385+ } else {
1386+ o.clearGhostText()
9331387 }
9341388 }
9351389
9361390 // 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) {
9381393 o.mu.Lock()
9391394 o.bangMode = false
9401395 o.bangSuggestions = nil
@@ -951,9 +1406,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) {
9511406 }
9521407 o.resizeAndCenter(rowCount)
9531408
954- // Select first item if available
1409+ // Select first item if available and update ghost text
9551410 if rowCount > 0 {
9561411 o.selectIndex(0)
1412+ // Update ghost text from first favorite
1413+ o.updateGhostFromSuggestion(query)
1414+ } else {
1415+ o.clearGhostText()
9571416 }
9581417 }
9591418
@@ -1272,21 +1731,21 @@ func (o *Omnibox) navigateToSelected() {
12721731
12731732 var targetURL string
12741733
1275- // Check if user typed a URL-like string (contains . and no spaces)
1276- // If so, navigate to that URL directly instead of the selection
1277- if o.looksLikeURL(entryText) {
1278- targetURL = o.buildURL(entryText)
1279- } else if idx < 0 {
1280- // No selection - use entry text as URL/search
1281- targetURL = o.buildURL(entryText)
1282- } else if mode == ViewModeHistory {
1283- if idx < len(suggestions) {
1284- targetURL = suggestions[idx].URL
1734+ // If user has selected a result, navigate to that result
1735+ // Otherwise, treat entry text as URL/search
1736+ if idx >= 0 {
1737+ if mode == ViewModeHistory {
1738+ if idx < len(suggestions) {
1739+ targetURL = suggestions[idx].URL
1740+ }
1741+ } else {
1742+ if idx < len(favorites) {
1743+ targetURL = favorites[idx].URL
1744+ }
12851745 }
12861746 } else {
1287- if idx < len(favorites) {
1288- targetURL = favorites[idx].URL
1289- }
1747+ // No selection - use entry text as URL/search
1748+ targetURL = o.buildURL(entryText)
12901749 }
12911750
12921751 if targetURL == "" {
@@ -1300,12 +1759,6 @@ func (o *Omnibox) navigateToSelected() {
13001759 }
13011760 }
13021761
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-
13091762 // toggleFavorite adds or removes the selected item from favorites.
13101763 // In History mode: adds the selected item to favorites
13111764 // In Favorites mode: removes the selected item from favorites
@@ -1542,8 +1995,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
15421995 return
15431996 }
15441997 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
15452004 o.mu.Unlock()
15462005
2006+ // Clear any stale ghost text display
2007+ if o.ghostLabel != nil {
2008+ o.ghostLabel.SetVisible(false)
2009+ }
2010+
15472011 // Set initial query
15482012 o.entry.SetText(query)
15492013
@@ -1604,9 +2068,15 @@ func (o *Omnibox) Hide(ctx context.Context) {
16042068
16052069 // Clear state
16062070 o.clearBangState()
2071+ o.clearGhostText()
16072072 o.entry.SetText("")
16082073 o.listBox.RemoveAll()
16092074
2075+ // Reset realInput
2076+ o.mu.Lock()
2077+ o.realInput = ""
2078+ o.mu.Unlock()
2079+
16102080 if o.onClose != nil {
16112081 o.onClose()
16122082 }
@@ -1662,9 +2132,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
16622132 }
16632133
16642134 // 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) {
16662137 var cb glib.SourceFunc = func(data uintptr) bool {
1667- o.updateSuggestions(suggestions)
2138+ o.updateSuggestions(suggestions, query)
16682139 return false // One-shot callback
16692140 }
16702141 glib.IdleAdd(&cb, 0)
@@ -1686,9 +2157,10 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
16862157 }
16872158
16882159 // 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) {
16902162 var cb glib.SourceFunc = func(data uintptr) bool {
1691- o.updateFavorites(favorites)
2163+ o.updateFavorites(favorites, query)
16922164 return false // One-shot callback, return false to remove source
16932165 }
16942166 glib.IdleAdd(&cb, 0)
@@ -1716,6 +2188,8 @@ func (o *Omnibox) Destroy() {
17162188
17172189 o.parentOverlay = nil
17182190 o.retainedCallbacks = nil
2191+ o.clickOutsideController = nil
2192+ o.clickOutsideCb = nil
17192193
17202194 if o.outerBox != nil {
17212195 o.outerBox.Unparent()
M internal/ui/coordinator/content.go +23 -0
@@ -24,6 +24,12 @@ import (
2424 const (
2525 aboutBlankURI = "about:blank"
2626 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
2733 )
2834
2935 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
@@ -936,6 +942,23 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
936942 return
937943 }
938944
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+
939962 // Show the WebView now that content is being painted
940963 // (WebViews are hidden on creation to avoid white flash)
941964 // 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(
9494 Msg("registered global shortcut")
9595
9696 if cfg != nil {
97- registerFromConfig := func(key string, action Action) {
98- binding, ok := ParseKeyString(key)
97+ // Map action names to action constants for global shortcuts
98+ actionMap := map[string]Action{
99+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
100+ "consume_or_expel_right": ActionConsumeOrExpelRight,
101+ "consume_or_expel_up": ActionConsumeOrExpelUp,
102+ "consume_or_expel_down": ActionConsumeOrExpelDown,
103+ }
104+
105+ for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
106+ action, ok := actionMap[actionName]
99107 if !ok {
100- log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut")
101- return
108+ continue
109+ }
110+ for _, keyStr := range actionBinding.Keys {
111+ binding, ok := ParseKeyString(keyStr)
112+ if !ok {
113+ log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
114+ continue
115+ }
116+ h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
117+ log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
102118 }
103- h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
104- log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut")
105119 }
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)
111120 }
112121
113122 // Attach to window
M internal/ui/input/keyboard.go +13 -0
@@ -79,6 +79,19 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
7979 h.onAction = fn
8080 }
8181
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+
8295 // SetOnModeChange sets the callback for mode changes (for UI updates).
8396 func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
8497 h.modal.SetOnModeChange(fn)
M internal/ui/input/shortcuts.go +19 -19
@@ -325,27 +325,27 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
325325 // This follows Zellij-style modal keyboard interface.
326326 //
327327 // However, these standard browser shortcuts ARE global:
328- if binding, ok := ParseKeyString(cfg.Shortcuts.ClosePane); ok {
329- s.Global[binding] = ActionClosePane // Ctrl+W closes active pane
330- }
331- if binding, ok := ParseKeyString(cfg.Shortcuts.NextTab); ok {
332- s.Global[binding] = ActionNextTab // Ctrl+Tab
333- }
334- if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok {
335- s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab
328+ // Map action names to action constants
329+ actionMap := map[string]Action{
330+ "close_pane": ActionClosePane,
331+ "next_tab": ActionNextTab,
332+ "previous_tab": ActionPreviousTab,
333+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
334+ "consume_or_expel_right": ActionConsumeOrExpelRight,
335+ "consume_or_expel_up": ActionConsumeOrExpelUp,
336+ "consume_or_expel_down": ActionConsumeOrExpelDown,
336337 }
337338
338- if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelLeft); ok {
339- s.Global[binding] = ActionConsumeOrExpelLeft
340- }
341- if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok {
342- s.Global[binding] = ActionConsumeOrExpelRight
343- }
344- if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok {
345- s.Global[binding] = ActionConsumeOrExpelUp
346- }
347- if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok {
348- s.Global[binding] = ActionConsumeOrExpelDown
339+ for actionName, actionBinding := range cfg.Shortcuts.Actions {
340+ action, ok := actionMap[actionName]
341+ if !ok {
342+ continue
343+ }
344+ for _, keyStr := range actionBinding.Keys {
345+ if binding, ok := ParseKeyString(keyStr); ok {
346+ s.Global[binding] = action
347+ }
348+ }
349349 }
350350 }
351351
M internal/ui/theme/css.go +10 -0
@@ -409,6 +409,16 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
409409 color: var(--accent);
410410 font-weight: 600;
411411 }
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+}
412422 `
413423 }
414424
M webui/eslint.config.js +21 -0
@@ -1,9 +1,30 @@
11 import js from "@eslint/js";
2+import svelte from "eslint-plugin-svelte";
3+import globals from "globals";
24 import tseslint from "typescript-eslint";
5+import svelteConfig from "./svelte.config.js";
36
47 export default tseslint.config(
58 js.configs.recommended,
69 ...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+ },
728 {
829 files: ["**/*.{js,mjs,cjs,ts}"],
930 languageOptions: {
M webui/package-lock.json +308 -7 generated file: collapsed
M webui/package.json +2 -0
@@ -27,6 +27,8 @@
2727 "@typescript-eslint/parser": "^7.0.0",
2828 "autoprefixer": "^10.4.20",
2929 "eslint": "^8.57.0",
30+ "eslint-plugin-svelte": "^3.14.0",
31+ "globals": "^17.1.0",
3032 "postcss": "^8.4.47",
3133 "svelte": "^5.0.0",
3234 "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 @@
22 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
33 import { buttonVariants } from "$lib/components/ui/button/index.js";
44 import { cn } from "$lib/utils.js";
5+ import type { Snippet } from "svelte";
6+ import type { MouseEventHandler } from "svelte/elements";
57
68 let {
79 ref = $bindable(null),
810 class: className,
11+ children,
12+ onclick,
913 ...restProps
10- }: AlertDialogPrimitive.ActionProps = $props();
14+ }: AlertDialogPrimitive.ActionProps & {
15+ children?: Snippet;
16+ onclick?: MouseEventHandler<HTMLButtonElement>;
17+ } = $props();
1118 </script>
1219
1320 <AlertDialogPrimitive.Action
1421 bind:ref
1522 class={cn(buttonVariants(), className)}
23+ {onclick}
1624 {...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 @@
22 import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
33 import { buttonVariants } from "$lib/components/ui/button/index.js";
44 import { cn } from "$lib/utils.js";
5+ import type { Snippet } from "svelte";
56
67 let {
78 ref = $bindable(null),
89 class: className,
10+ children,
911 ...restProps
10- }: AlertDialogPrimitive.CancelProps = $props();
12+ }: AlertDialogPrimitive.CancelProps & {
13+ children?: Snippet;
14+ } = $props();
1115 </script>
1216
1317 <AlertDialogPrimitive.Cancel
1418 bind:ref
1519 class={cn(buttonVariants({ variant: "outline" }), className)}
1620 {...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 @@
2121
2222 let open = $state(false);
2323
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-
3224 function handleInput(event: { hex: string | null }) {
3325 if (event.hex) {
34- hex = event.hex;
3526 value = event.hex;
3627 onValueChange?.(event.hex);
3728 }
@@ -60,7 +51,7 @@
6051 sideOffset={4}
6152 >
6253 <ColorPicker
63- bind:hex
54+ bind:hex={value}
6455 onInput={handleInput}
6556 isDialog={false}
6657 />
@@ -68,10 +59,10 @@
6859 <div class="mt-2 flex items-center gap-2">
6960 <span
7061 class="block h-6 w-6 shrink-0 border border-border"
71- style="background-color: {hex};"
62+ style="background-color: {value};"
7263 aria-hidden="true"
7364 ></span>
74- <span class="font-mono text-sm text-foreground">{hex}</span>
65+ <span class="font-mono text-sm text-foreground">{value}</span>
7566 </div>
7667 </Popover.Content>
7768 </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 @@
33 import { ModeWatcher } from "mode-watcher";
44 import ConfigShell from "./config/ConfigShell.svelte";
55 import ShortcutsTable from "./config/ShortcutsTable.svelte";
6+ import KeybindingsTab from "./config/KeybindingsTab.svelte";
67 import * as AlertDialog from "$lib/components/ui/alert-dialog";
78 import { Button } from "$lib/components/ui/button";
89 import { ColorPicker } from "$lib/components/ui/color-picker";
@@ -13,6 +14,12 @@
1314 import * as Tabs from "$lib/components/ui/tabs";
1415 import { FlaskConical, RefreshCw } from "@lucide/svelte";
1516
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+
1623 type ColorPalette = {
1724 background: string;
1825 surface: string;
@@ -118,8 +125,8 @@
118125 const response = await fetch("/api/config");
119126 if (!response.ok) throw new Error("Failed to fetch config");
120127 config = (await response.json()) as ConfigDTO;
121- } catch (e: any) {
122- loadError = e.message;
128+ } catch (e: unknown) {
129+ loadError = getErrorMessage(e);
123130 console.error("[config] load failed", e);
124131 } finally {
125132 loading = false;
@@ -139,14 +146,14 @@
139146 const response = await fetch("/api/config/default");
140147 if (!response.ok) throw new Error("Failed to fetch default config");
141148 config = (await response.json()) as ConfigDTO;
142- } catch (e: any) {
149+ } catch (e: unknown) {
143150 console.error("[config] reset defaults failed", e);
144151 }
145152 resetDialogOpen = false;
146153 }
147154
148155 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
149- const bridge = (window as any).webkit?.messageHandlers?.dumber;
156+ const bridge = window.webkit?.messageHandlers?.dumber;
150157 if (bridge && typeof bridge.postMessage === "function") {
151158 return bridge;
152159 }
@@ -154,7 +161,7 @@
154161 }
155162
156163 function getWebViewId(): number {
157- return (window as any).__dumber_webview_id || 0;
164+ return window.__dumber_webview_id || 0;
158165 }
159166
160167 async function saveConfig() {
@@ -194,7 +201,7 @@
194201 }
195202 };
196203
197- (window as any).__dumber_config_saved = (resp?: unknown) => {
204+ window.__dumber_config_saved = (resp?: unknown) => {
198205 clearSaveTimeout();
199206 console.debug("[config] save success", resp);
200207 saveSuccess = true;
@@ -212,7 +219,7 @@
212219 saveSuccess = false;
213220 }, 3000);
214221 };
215- (window as any).__dumber_config_error = (msg: unknown) => {
222+ window.__dumber_config_error = (msg: unknown) => {
216223 clearSaveTimeout();
217224 console.error("[config] save error", msg);
218225 saveError = typeof msg === "string" ? msg : "Failed to save config";
@@ -227,14 +234,13 @@
227234 webview_id: webviewId,
228235 payload,
229236 });
230- console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length });
231237 } catch (postErr) {
232238 console.error("[config] postMessage threw", postErr);
233239 throw postErr;
234240 }
235- } catch (e: any) {
241+ } catch (e: unknown) {
236242 console.error("[config] save exception", e);
237- saveError = e.message;
243+ saveError = getErrorMessage(e);
238244 saving = false;
239245 }
240246 }
@@ -246,8 +252,8 @@
246252
247253 onMount(() => {
248254 console.debug("[config] mount", {
249- webviewId: (window as any).__dumber_webview_id,
250- hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber),
255+ webviewId: window.__dumber_webview_id,
256+ hasBridge: Boolean(window.webkit?.messageHandlers?.dumber),
251257 });
252258
253259 const onThemeChanged = (e: Event) => {
@@ -298,6 +304,7 @@
298304 <Tabs.List class="bg-muted/60">
299305 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
300306 <Tabs.Trigger value="search">Search</Tabs.Trigger>
307+ <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
301308 <Tabs.Trigger value="performance">Performance</Tabs.Trigger>
302309 </Tabs.List>
303310 </div>
@@ -340,7 +347,12 @@
340347 </div>
341348 <div class="space-y-2">
342349 <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+ />
344356 </div>
345357 </div>
346358
@@ -424,6 +436,10 @@
424436 </Card.Root>
425437 </Tabs.Content>
426438
439+ <Tabs.Content value="keybindings">
440+ <KeybindingsTab />
441+ </Tabs.Content>
442+
427443 <Tabs.Content value="performance">
428444 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
429445 <Card.Header>
@@ -614,35 +630,37 @@
614630 <span>Theme x{themeEvents}</span>
615631 {/if}
616632 </div>
617- <div class="flex items-center gap-3">
618- <AlertDialog.Root bind:open={resetDialogOpen}>
619- <AlertDialog.Trigger disabled={saving}>
620- {#snippet child({ props })}
621- <Button variant="outline" {...props} type="button">
622- Reset Defaults
623- </Button>
624- {/snippet}
625- </AlertDialog.Trigger>
626- <AlertDialog.Content>
627- <AlertDialog.Header>
628- <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
629- <AlertDialog.Description>
630- This will reset all settings on this page to their default values.
631- You will still need to click Save to persist the changes.
632- </AlertDialog.Description>
633- </AlertDialog.Header>
634- <AlertDialog.Footer>
635- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
636- <AlertDialog.Action onclick={doResetToDefaults}>
637- Reset Defaults
638- </AlertDialog.Action>
639- </AlertDialog.Footer>
640- </AlertDialog.Content>
641- </AlertDialog.Root>
642- <Button onclick={saveConfig} disabled={saving} type="button">
643- {saving ? "Saving…" : "Save"}
644- </Button>
645- </div>
633+ {#if activeTab !== "keybindings"}
634+ <div class="flex items-center gap-3">
635+ <AlertDialog.Root bind:open={resetDialogOpen}>
636+ <AlertDialog.Trigger disabled={saving}>
637+ {#snippet child({ props })}
638+ <Button variant="outline" {...props} type="button">
639+ Reset Defaults
640+ </Button>
641+ {/snippet}
642+ </AlertDialog.Trigger>
643+ <AlertDialog.Content>
644+ <AlertDialog.Header>
645+ <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
646+ <AlertDialog.Description>
647+ This will reset all settings on this page to their default values.
648+ You will still need to click Save to persist the changes.
649+ </AlertDialog.Description>
650+ </AlertDialog.Header>
651+ <AlertDialog.Footer>
652+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
653+ <AlertDialog.Action onclick={doResetToDefaults}>
654+ Reset Defaults
655+ </AlertDialog.Action>
656+ </AlertDialog.Footer>
657+ </AlertDialog.Content>
658+ </AlertDialog.Root>
659+ <Button onclick={saveConfig} disabled={saving} type="button">
660+ {saving ? "Saving…" : "Save"}
661+ </Button>
662+ </div>
663+ {/if}
646664 </div>
647665
648666 <!-- Save error display -->
M webui/src/pages/ErrorPage.svelte +11 -2
@@ -70,7 +70,11 @@
7070 const handler = (event: MessageEvent) => {
7171 if (event.data?.requestId === requestId) {
7272 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+ }
7478 }
7579 };
7680 window.addEventListener('message', handler);
@@ -110,7 +114,11 @@
110114 }
111115
112116 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+ }
114122 }
115123
116124 function goBack() {
@@ -144,6 +152,7 @@
144152 <Card.Header class="text-center">
145153 <div class="flex justify-center mb-4">
146154 <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 -->
147156 {@html icons[config.icon] || icons.error}
148157 </svg>
149158 </div>
M webui/src/pages/config/ConfigShell.svelte +3 -4
@@ -9,14 +9,12 @@
99 let { children }: Props = $props();
1010
1111 function navigateWithViewTransition(url: string) {
12- const doc = document as any;
13-
14- if (typeof doc?.startViewTransition !== 'function') {
12+ if (typeof document.startViewTransition !== 'function') {
1513 window.location.href = url;
1614 return;
1715 }
1816
19- const transition = doc.startViewTransition(() => {
17+ const transition = document.startViewTransition(() => {
2018 document.documentElement.dataset.vt = 'leaving';
2119 });
2220
@@ -34,6 +32,7 @@
3432 <svelte:head>
3533 <title>dumb://config</title>
3634 <meta name="description" content="Dumber Browser - Settings" />
35+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
3736 {@html `<style>
3837 html, body { margin: 0; padding: 0; }
3938 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 @@
1919
2020 // Convert data to Frappe Charts format (ensure all 24 hours)
2121 const chartData = $derived.by(() => {
22+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation
2223 const visitsByHour = new Map<number, number>();
2324 hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));
2425
M webui/src/pages/homepage/favorites/FavoriteEditor.svelte +1 -1
@@ -139,7 +139,7 @@
139139 <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label>
140140 <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}>
141141 <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)}
143143 <option value={key}>{key}</option>
144144 {/each}
145145 </select>
M webui/src/pages/homepage/messaging.ts +34 -27
@@ -23,8 +23,12 @@ import { homepageState } from './state.svelte';
2323 // REQUEST TRACKING
2424 // ═══════════════════════════════════════════════════════════════════════════════
2525
26-interface PendingRequest<T = unknown> {
27- resolve: (value: T) => void;
26+// Pending requests store resolvers for different response types.
27+// The resolve function accepts `unknown` since requests with different
28+// response types share the same map. Type safety is maintained at the
29+// sendMessage<T> API level.
30+interface PendingRequest {
31+ resolve: (value: unknown) => void;
2832 reject: (error: Error) => void;
2933 timeout: ReturnType<typeof setTimeout>;
3034 }
@@ -38,8 +42,8 @@ let callbacksInitialized = false;
3842 // WEBKIT BRIDGE UTILITIES
3943 // ═══════════════════════════════════════════════════════════════════════════════
4044
41-function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
42- const bridge = (window as any).webkit?.messageHandlers?.dumber;
45+function getWebKitBridge(): DumberBridge | null {
46+ const bridge = window.webkit?.messageHandlers?.dumber;
4347 if (bridge && typeof bridge.postMessage === 'function') {
4448 return bridge;
4549 }
@@ -47,7 +51,7 @@ function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
4751 }
4852
4953 function getWebViewId(): number {
50- return (window as any).__dumber_webview_id || 0;
54+ return window.__dumber_webview_id ?? 0;
5155 }
5256
5357 function generateRequestId(type: string): string {
@@ -62,9 +66,7 @@ function initializeCallbacks(): void {
6266 if (callbacksInitialized) return;
6367
6468 // Generic response handler - accepts a single Response object from Go backend
65- (window as any).__dumber_homepage_response = (
66- response: { requestId: string; success: boolean; data?: unknown; error?: string }
67- ) => {
69+ window.__dumber_homepage_response = (response) => {
6870 const request = pendingRequests.get(response.requestId);
6971 if (!request) return;
7072
@@ -74,61 +76,60 @@ function initializeCallbacks(): void {
7476 if (response.success) {
7577 request.resolve(response.data);
7678 } else {
77- request.reject(new Error(response.error || 'Unknown error'));
79+ request.reject(new Error(response.error ?? 'Unknown error'));
7880 }
7981 };
8082
8183 // Legacy callbacks for backward compatibility
8284 // 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) => {
8486 handleLegacyOrResponseCallback(dataOrResponse);
8587 };
8688
87- (window as any).__dumber_folders = (dataOrResponse: unknown) => {
89+ window.__dumber_folders = (dataOrResponse) => {
8890 handleLegacyOrResponseCallback(dataOrResponse);
8991 };
9092
91- (window as any).__dumber_tags = (dataOrResponse: unknown) => {
93+ window.__dumber_tags = (dataOrResponse) => {
9294 handleLegacyOrResponseCallback(dataOrResponse);
9395 };
9496
95- (window as any).__dumber_favorites = (dataOrResponse: unknown) => {
97+ window.__dumber_favorites = (dataOrResponse) => {
9698 handleLegacyOrResponseCallback(dataOrResponse);
9799 };
98100
99- (window as any).__dumber_analytics = (dataOrResponse: unknown) => {
101+ window.__dumber_analytics = (dataOrResponse) => {
100102 handleLegacyOrResponseCallback(dataOrResponse);
101103 };
102104
103- (window as any).__dumber_domain_stats = (dataOrResponse: unknown) => {
105+ window.__dumber_domain_stats = (dataOrResponse) => {
104106 handleLegacyOrResponseCallback(dataOrResponse);
105107 };
106108
107- (window as any).__dumber_history_search_results = (dataOrResponse: unknown) => {
109+ window.__dumber_history_search_results = (dataOrResponse) => {
108110 handleLegacyOrResponseCallback(dataOrResponse);
109111 };
110112
111- (window as any).__dumber_history_deleted = (dataOrResponse: unknown) => {
113+ window.__dumber_history_deleted = (dataOrResponse) => {
112114 handleLegacyOrResponseCallback(dataOrResponse);
113115 };
114116
115- (window as any).__dumber_history_cleared = (dataOrResponse: unknown) => {
117+ window.__dumber_history_cleared = (dataOrResponse) => {
116118 handleLegacyOrResponseCallback(dataOrResponse);
117119 };
118120
119- (window as any).__dumber_error = (errorOrResponse: unknown) => {
121+ window.__dumber_error = (errorOrResponse) => {
120122 // Handle both formats: direct error string or Response object with error
121- const response = errorOrResponse as { requestId?: string; error?: string } | string;
122- if (typeof response === 'string') {
123+ if (typeof errorOrResponse === 'string') {
123124 // Legacy format: direct error string
124- console.error('[messaging] Legacy error:', response);
125- } else if (response && typeof response === 'object') {
125+ console.error('[messaging] Legacy error:', errorOrResponse);
126+ } else if (errorOrResponse && typeof errorOrResponse === 'object') {
126127 // Response object format
127- const request = pendingRequests.get(response.requestId || 'default');
128+ const request = pendingRequests.get(errorOrResponse.requestId ?? 'default');
128129 if (request) {
129130 clearTimeout(request.timeout);
130- pendingRequests.delete(response.requestId || 'default');
131- request.reject(new Error(response.error || 'Unknown error'));
131+ pendingRequests.delete(errorOrResponse.requestId ?? 'default');
132+ request.reject(new Error(errorOrResponse.error ?? 'Unknown error'));
132133 }
133134 }
134135 };
@@ -188,7 +189,13 @@ async function sendMessage<T = unknown>(
188189 reject(new Error(`Request timeout: ${type}`));
189190 }, REQUEST_TIMEOUT_MS);
190191
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+ });
192199
193200 try {
194201 bridge.postMessage({
M webui/src/pages/homepage/panels/HistoryPanel.svelte +2 -0
@@ -37,6 +37,7 @@
3737 });
3838
3939 // Recompute timeline from filtered entries
40+ /* eslint-disable svelte/prefer-svelte-reactivity -- temporary Map/Date for data grouping, not reactive state */
4041 const displayedTimeline = $derived(() => {
4142 const entries = displayedEntries();
4243 if (entries.length === 0) return [];
@@ -78,6 +79,7 @@
7879
7980 return result.sort((a, b) => b.date.localeCompare(a.date));
8081 });
82+ /* eslint-enable svelte/prefer-svelte-reactivity */
8183
8284 const handleSelectEntry = (entry: HistoryEntry) => {
8385 navigateTo(entry.url);
M webui/src/pages/homepage/shell/CommandPalette.svelte +1 -1
@@ -165,7 +165,7 @@
165165
166166 // Reset selection when query changes
167167 $effect(() => {
168- query; // Track query
168+ void query; // Track query changes
169169 selectedIndex = 0;
170170 });
171171
M webui/src/pages/homepage/shell/HomepageShell.svelte +6 -9
@@ -35,14 +35,12 @@
3535 };
3636
3737 function navigateWithViewTransition(url: string) {
38- const doc = document as any;
39-
40- if (typeof doc?.startViewTransition !== 'function') {
38+ if (typeof document.startViewTransition !== 'function') {
4139 window.location.href = url;
4240 return;
4341 }
4442
45- const transition = doc.startViewTransition(() => {
43+ const transition = document.startViewTransition(() => {
4644 document.documentElement.dataset.vt = 'leaving';
4745 });
4846
@@ -53,17 +51,15 @@
5351
5452 const toggleTheme = () => {
5553 const nextMode = themeMode === 'dark' ? 'light' : 'dark';
56- const manager = (window as any).__dumber_color_scheme_manager as
57- | { setUserPreference?: (theme: ThemeMode) => void }
58- | undefined;
54+ const manager = window.__dumber_color_scheme_manager;
5955
6056 if (manager?.setUserPreference) {
6157 manager.setUserPreference(nextMode);
6258 return;
6359 }
6460
65- if ((window as any).__dumber_setTheme) {
66- (window as any).__dumber_setTheme(nextMode);
61+ if (window.__dumber_setTheme) {
62+ window.__dumber_setTheme(nextMode);
6763 localStorage.setItem('dumber.theme', nextMode);
6864 return;
6965 }
@@ -109,6 +105,7 @@
109105 <svelte:head>
110106 <title>dumb://home</title>
111107 <meta name="description" content="Dumber Browser - Homepage" />
108+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
112109 {@html `<style>
113110 html, body { margin: 0; padding: 0; }
114111 html { background: var(--background, #0a0a0a); }
M webui/src/pages/homepage/shell/KeyboardHints.svelte +4 -4
@@ -136,7 +136,7 @@
136136 {#each navHints as hint (hint.description)}
137137 <div class="hint-row">
138138 <div class="hint-keys">
139- {#each hint.keys as key, i}
139+ {#each hint.keys as key, i (key)}
140140 {#if i > 0}<span class="key-sep">/</span>{/if}
141141 <kbd>{key}</kbd>
142142 {/each}
@@ -157,7 +157,7 @@
157157 {#each panelSwitchHints as hint (hint.description)}
158158 <div class="hint-row">
159159 <div class="hint-keys">
160- {#each hint.keys as key, i}
160+ {#each hint.keys as key, i (key)}
161161 {#if i > 0}<span class="key-sep">/</span>{/if}
162162 <kbd>{key}</kbd>
163163 {/each}
@@ -179,7 +179,7 @@
179179 {#each panelHints as hint (hint.description)}
180180 <div class="hint-row">
181181 <div class="hint-keys">
182- {#each hint.keys as key, i}
182+ {#each hint.keys as key, i (key)}
183183 {#if i > 0}<span class="key-sep">/</span>{/if}
184184 <kbd>{key}</kbd>
185185 {/each}
@@ -201,7 +201,7 @@
201201 {#each quickAccessHints as hint (hint.description)}
202202 <div class="hint-row">
203203 <div class="hint-keys">
204- {#each hint.keys as key, i}
204+ {#each hint.keys as key, i (key)}
205205 {#if i > 0}<span class="key-sep">/</span>{/if}
206206 <kbd>{key}</kbd>
207207 {/each}
M webui/src/pages/homepage/shell/StatusBar.svelte +2 -1
@@ -38,7 +38,7 @@
3838 label: 'entries',
3939 filtered: false,
4040 };
41- case 'favorites':
41+ case 'favorites': {
4242 const filtered = homepageState.filteredFavorites;
4343 const total = homepageState.favorites.length;
4444 return {
@@ -46,6 +46,7 @@
4646 label: filtered.length === total ? 'items' : `of ${total}`,
4747 filtered: filtered.length !== total,
4848 };
49+ }
4950 case 'analytics':
5051 return {
5152 count: homepageState.domainStats.length,
M webui/src/pages/homepage/state.svelte.ts +2 -0
@@ -71,6 +71,7 @@ let pendingKeyPrefix = $state<string | null>(null);
7171 // HELPER FUNCTIONS
7272 // ═══════════════════════════════════════════════════════════════════════════════
7373
74+/* eslint-disable svelte/prefer-svelte-reactivity -- helper function uses temporary Map/Date, not reactive state */
7475 function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
7576 const groups = new Map<string, HistoryEntry[]>();
7677 const today = new Date();
@@ -109,6 +110,7 @@ function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
109110
110111 return result.sort((a, b) => b.date.localeCompare(a.date));
111112 }
113+/* eslint-enable svelte/prefer-svelte-reactivity */
112114
113115 // ═══════════════════════════════════════════════════════════════════════════════
114116 // EXPORTED STORE
M webui/src/pages/homepage/types.ts +3 -1
@@ -3,6 +3,8 @@
33 // Mirrors Go models from internal/db/models.go and related query files
44 // ═══════════════════════════════════════════════════════════════════════════════
55
6+import type { Component } from 'svelte';
7+
68 // ─────────────────────────────────────────────────────────────────────────────
79 // History Types
810 // ─────────────────────────────────────────────────────────────────────────────
@@ -110,7 +112,7 @@ export interface CommandPaletteItem {
110112 id: string;
111113 label: string;
112114 description?: string;
113- icon?: import('svelte').Component | string;
115+ icon?: Component | string;
114116 action: () => void;
115117 shortcut?: string;
116118 }
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