detached 9e989c0

Merge pull request #141 from bnema/feat/keybindings-ui-and-syntax

feat: keybindings UI editor, clean architecture, and config improvements
commit
9e989c089a7c0d2759ae5f8fcecbee4c3e228d7c
author
bnema · · 2026-01-24 14:36 UTC
committer
GitHub · · 2026-01-24 14:36 UTC
merge of
7ec6282 + 8dba5bf · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
54 files · +3930 -358
M .mockery.yaml +3 -0
@@ -10,6 +10,7 @@ packages:
1010 interfaces:
1111 ConfigMigrator: {}
1212 ConfigSchemaProvider: {}
13+ ConfigTransformer: {}
1314 DatabaseProvider: {}
1415 DiffFormatter: {}
1516 SessionLogger: {}
@@ -27,6 +28,8 @@ packages:
2728 FocusedInputProvider: {}
2829 AccentPickerUI: {}
2930 FontDetector: {}
31+ KeybindingsProvider: {}
32+ KeybindingsSaver: {}
3033 github.com/bnema/dumber/internal/domain/repository:
3134 config:
3235 dir: '{{.InterfaceDir}}/mocks'
M CHANGELOG.md +15 -0
@@ -4,7 +4,22 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+- **Visual keybindings editor**: New Keybindings tab in `dumb://config` allows editing all keyboard shortcuts via a visual UI. Supports key capture, reset to defaults, and per-binding reset. Fixes #123.
9+- **Keybindings hot reload**: Changed keybindings take effect immediately without restarting the browser.
10+- **Ordered config file**: Config file sections are now written in alphabetical order for better readability and deterministic output.
11+
12+### Changed
13+- **Simplified keybinding syntax**: Keybindings now use symbol syntax (`[`, `]`, `{`, `}`) instead of verbose GTK names (`bracketleft`, `braceleft`). Fixes #122.
14+- **BREAKING: Global shortcuts config structure**: `workspace.shortcuts` changed from individual string fields to unified `Actions` map with `ActionBinding` (keys + description). Existing custom shortcuts in config.toml will be replaced with defaults. Use the new Keybindings UI to reconfigure.
15+- **Keybindings clean architecture**: Refactored keybindings handler to follow ports/adapters pattern. Business logic moved from handler to use cases, config access via gateway adapter instead of global singleton.
16+
17+### Upgrade Notes
18+> **Recommended**: Delete your `config.toml` and restart dumber to regenerate a fresh config with the new keybinding structure and alphabetically sorted sections. Your settings will reset to defaults, but you can reconfigure keybindings via the new visual editor at `dumb://config` → Keybindings tab.
19+
720 ### Fixed
21+- **Config save/read race condition**: Fixed UI scale and other config values returning stale data after save. Implemented atomic file writes (temp file + rename) and `skipNextReload` flag to prevent fsnotify-triggered reload from overwriting correct in-memory config with stale viper cache.
22+- **WebUI lint warnings**: Added ESLint Svelte plugin and resolved 33 lint errors and 14 `any` type warnings across all components. Created `dumber.d.ts` for proper TypeScript types for WebKit bridge communication.
823 - **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.
924 - **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.
1025 - **Noisy gesture navigation logs**: Mouse button back/forward navigation errors (expected when no history) now log at debug level instead of error level.
M docs/CONFIG.md +28 -13
@@ -236,10 +236,10 @@ move-pane-to-tab = ["m"]
236236 move-pane-to-next-tab = ["M", "shift+m"]
237237
238238 # 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", "}"]
239+consume-or-expel-left = ["["]
240+consume-or-expel-right = ["]"]
241+consume-or-expel-up = ["{"]
242+consume-or-expel-down = ["}"]
243243
244244 focus-right = ["shift+arrowright", "shift+l"]
245245 focus-left = ["shift+arrowleft", "shift+h"]
@@ -307,16 +307,31 @@ Notes:
307307
308308 ### Global Shortcuts
309309
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) |
310+Global shortcuts are configured under `workspace.shortcuts.actions` using the same `ActionBinding` structure as modal keybindings:
311+
312+| Action | Default Keys | Description |
313+|--------|--------------|-------------|
314+| `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) |
315+| `next_tab` | `ctrl+tab` | Switch to next tab |
316+| `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |
317+| `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |
318+| `consume_or_expel_right` | `alt+]` | Consume into right sibling stack, or expel right if stacked |
319+| `consume_or_expel_up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked |
320+| `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked |
319321
322+**Example:**
323+```toml
324+[workspace.shortcuts.actions.close_pane]
325+ keys = ["ctrl+w"]
326+ desc = "Close active pane"
327+
328+[workspace.shortcuts.actions.next_tab]
329+ keys = ["ctrl+tab", "alt+l"]
330+ desc = "Switch to next tab"
331+```
332+
333+> **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually.
334+>
320335 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
321336 >
322337 > **Note:** Consume-or-expel is experimental and may change behavior between releases.
A internal/application/port/config_transformer.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+// ConfigTransformer transforms legacy config formats to current format.
4+type ConfigTransformer interface {
5+ // TransformLegacyActions converts old-format action bindings (slices)
6+ // to new ActionBinding format (map with keys and desc).
7+ // Modifies the rawConfig map in place.
8+ TransformLegacyActions(rawConfig map[string]any)
9+}
A internal/application/port/keybindings.go +66 -0
@@ -0,0 +1,66 @@
1+package port
2+
3+import "context"
4+
5+// KeybindingEntry represents a single keybinding for the UI.
6+type KeybindingEntry struct {
7+ Action string `json:"action"`
8+ Description string `json:"description"`
9+ Keys []string `json:"keys"`
10+ DefaultKeys []string `json:"default_keys"`
11+ IsCustom bool `json:"is_custom"`
12+}
13+
14+// KeybindingGroup represents keybindings for a mode.
15+type KeybindingGroup struct {
16+ Mode string `json:"mode"`
17+ DisplayName string `json:"display_name"`
18+ Bindings []KeybindingEntry `json:"bindings"`
19+ Activation string `json:"activation,omitempty"`
20+}
21+
22+// KeybindingsConfig represents all keybindings for the UI.
23+type KeybindingsConfig struct {
24+ Groups []KeybindingGroup `json:"groups"`
25+}
26+
27+// SetKeybindingRequest represents a request to update a keybinding.
28+type SetKeybindingRequest struct {
29+ RequestID string `json:"requestId"`
30+ Mode string `json:"mode"`
31+ Action string `json:"action"`
32+ Keys []string `json:"keys"`
33+}
34+
35+// ResetKeybindingRequest represents a request to reset a keybinding.
36+type ResetKeybindingRequest struct {
37+ RequestID string `json:"requestId"`
38+ Mode string `json:"mode"`
39+ Action string `json:"action"`
40+}
41+
42+// KeybindingConflict represents a detected conflict.
43+type KeybindingConflict struct {
44+ ConflictingAction string `json:"conflicting_action"`
45+ ConflictingMode string `json:"conflicting_mode"`
46+ Key string `json:"key"`
47+}
48+
49+// SetKeybindingResponse is the response from setting a keybinding.
50+type SetKeybindingResponse struct {
51+ Conflicts []KeybindingConflict `json:"conflicts"`
52+}
53+
54+// KeybindingsProvider provides keybinding configuration data.
55+type KeybindingsProvider interface {
56+ GetKeybindings(ctx context.Context) (KeybindingsConfig, error)
57+ GetDefaultKeybindings(ctx context.Context) (KeybindingsConfig, error)
58+ CheckConflicts(ctx context.Context, mode, action string, keys []string) ([]KeybindingConflict, error)
59+}
60+
61+// KeybindingsSaver persists keybinding changes.
62+type KeybindingsSaver interface {
63+ SetKeybinding(ctx context.Context, req SetKeybindingRequest) error
64+ ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
65+ ResetAllKeybindings(ctx context.Context) error
66+}
M internal/application/port/mocks/mocks.go +486 -0
@@ -1037,6 +1037,73 @@ func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []ent
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/keybindings.go +140 -0
@@ -0,0 +1,140 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// validModes defines the valid keybinding modes.
11+var validModes = map[string]bool{
12+ "global": true,
13+ "pane": true,
14+ "tab": true,
15+ "resize": true,
16+ "session": true,
17+}
18+
19+// GetKeybindingsUseCase retrieves all keybindings.
20+type GetKeybindingsUseCase struct {
21+ provider port.KeybindingsProvider
22+}
23+
24+// NewGetKeybindingsUseCase creates a new GetKeybindingsUseCase.
25+func NewGetKeybindingsUseCase(provider port.KeybindingsProvider) *GetKeybindingsUseCase {
26+ return &GetKeybindingsUseCase{provider: provider}
27+}
28+
29+// Execute retrieves all keybindings.
30+func (uc *GetKeybindingsUseCase) Execute(ctx context.Context) (port.KeybindingsConfig, error) {
31+ if uc == nil || uc.provider == nil {
32+ return port.KeybindingsConfig{}, fmt.Errorf("keybindings provider is nil")
33+ }
34+ return uc.provider.GetKeybindings(ctx)
35+}
36+
37+// SetKeybindingUseCase updates a single keybinding.
38+type SetKeybindingUseCase struct {
39+ provider port.KeybindingsProvider
40+ saver port.KeybindingsSaver
41+}
42+
43+// NewSetKeybindingUseCase creates a new SetKeybindingUseCase.
44+func NewSetKeybindingUseCase(provider port.KeybindingsProvider, saver port.KeybindingsSaver) *SetKeybindingUseCase {
45+ return &SetKeybindingUseCase{provider: provider, saver: saver}
46+}
47+
48+// Execute updates a keybinding and returns any conflicts.
49+func (uc *SetKeybindingUseCase) Execute(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
50+ if uc == nil || uc.saver == nil {
51+ return port.SetKeybindingResponse{}, fmt.Errorf("keybindings saver is nil")
52+ }
53+
54+ if err := validateSetKeybindingRequest(req); err != nil {
55+ return port.SetKeybindingResponse{}, err
56+ }
57+
58+ // Check for conflicts before saving
59+ var conflicts []port.KeybindingConflict
60+ if uc.provider != nil {
61+ var err error
62+ conflicts, err = uc.provider.CheckConflicts(ctx, req.Mode, req.Action, req.Keys)
63+ if err != nil {
64+ return port.SetKeybindingResponse{}, fmt.Errorf("failed to check conflicts: %w", err)
65+ }
66+ }
67+
68+ if err := uc.saver.SetKeybinding(ctx, req); err != nil {
69+ return port.SetKeybindingResponse{}, err
70+ }
71+
72+ return port.SetKeybindingResponse{Conflicts: conflicts}, nil
73+}
74+
75+// ResetKeybindingUseCase resets a keybinding to default.
76+type ResetKeybindingUseCase struct {
77+ saver port.KeybindingsSaver
78+}
79+
80+// NewResetKeybindingUseCase creates a new ResetKeybindingUseCase.
81+func NewResetKeybindingUseCase(saver port.KeybindingsSaver) *ResetKeybindingUseCase {
82+ return &ResetKeybindingUseCase{saver: saver}
83+}
84+
85+// Execute resets a keybinding to default.
86+func (uc *ResetKeybindingUseCase) Execute(ctx context.Context, req port.ResetKeybindingRequest) error {
87+ if uc == nil || uc.saver == nil {
88+ return fmt.Errorf("keybindings saver is nil")
89+ }
90+
91+ if err := validateResetKeybindingRequest(req); err != nil {
92+ return err
93+ }
94+
95+ return uc.saver.ResetKeybinding(ctx, req)
96+}
97+
98+// ResetAllKeybindingsUseCase resets all keybindings to defaults.
99+type ResetAllKeybindingsUseCase struct {
100+ saver port.KeybindingsSaver
101+}
102+
103+// NewResetAllKeybindingsUseCase creates a new ResetAllKeybindingsUseCase.
104+func NewResetAllKeybindingsUseCase(saver port.KeybindingsSaver) *ResetAllKeybindingsUseCase {
105+ return &ResetAllKeybindingsUseCase{saver: saver}
106+}
107+
108+// Execute resets all keybindings to defaults.
109+func (uc *ResetAllKeybindingsUseCase) Execute(ctx context.Context) error {
110+ if uc == nil || uc.saver == nil {
111+ return fmt.Errorf("keybindings saver is nil")
112+ }
113+ return uc.saver.ResetAllKeybindings(ctx)
114+}
115+
116+func validateSetKeybindingRequest(req port.SetKeybindingRequest) error {
117+ if req.Mode == "" {
118+ return fmt.Errorf("mode is required")
119+ }
120+ if req.Action == "" {
121+ return fmt.Errorf("action is required")
122+ }
123+ if !validModes[req.Mode] {
124+ return fmt.Errorf("invalid mode: %s", req.Mode)
125+ }
126+ return nil
127+}
128+
129+func validateResetKeybindingRequest(req port.ResetKeybindingRequest) error {
130+ if req.Mode == "" {
131+ return fmt.Errorf("mode is required")
132+ }
133+ if req.Action == "" {
134+ return fmt.Errorf("action is required")
135+ }
136+ if !validModes[req.Mode] {
137+ return fmt.Errorf("invalid mode: %s", req.Mode)
138+ }
139+ return nil
140+}
A internal/application/usecase/keybindings_test.go +343 -0
@@ -0,0 +1,343 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+)
16+
17+func TestGetKeybindingsUseCase_Execute(t *testing.T) {
18+ t.Run("returns keybindings from provider", func(t *testing.T) {
19+ // Arrange
20+ mockProvider := mocks.NewMockKeybindingsProvider(t)
21+ expected := port.KeybindingsConfig{
22+ Groups: []port.KeybindingGroup{
23+ {
24+ Mode: "global",
25+ DisplayName: "Global Shortcuts",
26+ Bindings: []port.KeybindingEntry{
27+ {Action: "close_pane", Keys: []string{"ctrl+w"}},
28+ },
29+ },
30+ },
31+ }
32+ mockProvider.EXPECT().GetKeybindings(mock.Anything).Return(expected, nil)
33+
34+ uc := usecase.NewGetKeybindingsUseCase(mockProvider)
35+
36+ // Act
37+ result, err := uc.Execute(context.Background())
38+
39+ // Assert
40+ require.NoError(t, err)
41+ assert.Equal(t, expected, result)
42+ mock.AssertExpectationsForObjects(t, mockProvider)
43+ })
44+
45+ t.Run("returns error when provider fails", func(t *testing.T) {
46+ // Arrange
47+ mockProvider := mocks.NewMockKeybindingsProvider(t)
48+ mockProvider.EXPECT().GetKeybindings(mock.Anything).Return(port.KeybindingsConfig{}, errors.New("provider error"))
49+
50+ uc := usecase.NewGetKeybindingsUseCase(mockProvider)
51+
52+ // Act
53+ _, err := uc.Execute(context.Background())
54+
55+ // Assert
56+ require.Error(t, err)
57+ assert.Contains(t, err.Error(), "provider error")
58+ mock.AssertExpectationsForObjects(t, mockProvider)
59+ })
60+
61+ t.Run("returns error when provider is nil", func(t *testing.T) {
62+ // Arrange
63+ uc := usecase.NewGetKeybindingsUseCase(nil)
64+
65+ // Act
66+ _, err := uc.Execute(context.Background())
67+
68+ // Assert
69+ require.Error(t, err)
70+ assert.Contains(t, err.Error(), "provider is nil")
71+ })
72+}
73+
74+func TestSetKeybindingUseCase_Execute(t *testing.T) {
75+ t.Run("successfully sets keybinding", func(t *testing.T) {
76+ // Arrange
77+ mockProvider := mocks.NewMockKeybindingsProvider(t)
78+ mockSaver := mocks.NewMockKeybindingsSaver(t)
79+ req := port.SetKeybindingRequest{
80+ Mode: "pane",
81+ Action: "split-right",
82+ Keys: []string{"r"},
83+ }
84+ mockProvider.EXPECT().CheckConflicts(mock.Anything, req.Mode, req.Action, req.Keys).Return(nil, nil)
85+ mockSaver.EXPECT().SetKeybinding(mock.Anything, req).Return(nil)
86+
87+ uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver)
88+
89+ // Act
90+ resp, err := uc.Execute(context.Background(), req)
91+
92+ // Assert
93+ require.NoError(t, err)
94+ assert.Empty(t, resp.Conflicts)
95+ mock.AssertExpectationsForObjects(t, mockProvider, mockSaver)
96+ })
97+
98+ t.Run("returns conflicts when detected", func(t *testing.T) {
99+ // Arrange
100+ mockProvider := mocks.NewMockKeybindingsProvider(t)
101+ mockSaver := mocks.NewMockKeybindingsSaver(t)
102+ req := port.SetKeybindingRequest{
103+ Mode: "pane",
104+ Action: "split-right",
105+ Keys: []string{"r"},
106+ }
107+ conflicts := []port.KeybindingConflict{
108+ {ConflictingAction: "other-action", ConflictingMode: "pane", Key: "r"},
109+ }
110+ mockProvider.EXPECT().CheckConflicts(mock.Anything, req.Mode, req.Action, req.Keys).Return(conflicts, nil)
111+ mockSaver.EXPECT().SetKeybinding(mock.Anything, req).Return(nil)
112+
113+ uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver)
114+
115+ // Act
116+ resp, err := uc.Execute(context.Background(), req)
117+
118+ // Assert
119+ require.NoError(t, err)
120+ assert.Equal(t, conflicts, resp.Conflicts)
121+ mock.AssertExpectationsForObjects(t, mockProvider, mockSaver)
122+ })
123+
124+ t.Run("returns error when saver fails", func(t *testing.T) {
125+ // Arrange
126+ mockProvider := mocks.NewMockKeybindingsProvider(t)
127+ mockSaver := mocks.NewMockKeybindingsSaver(t)
128+ req := port.SetKeybindingRequest{
129+ Mode: "pane",
130+ Action: "split-right",
131+ Keys: []string{"r"},
132+ }
133+ mockProvider.EXPECT().CheckConflicts(mock.Anything, req.Mode, req.Action, req.Keys).Return(nil, nil)
134+ mockSaver.EXPECT().SetKeybinding(mock.Anything, req).Return(errors.New("save error"))
135+
136+ uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver)
137+
138+ // Act
139+ _, err := uc.Execute(context.Background(), req)
140+
141+ // Assert
142+ require.Error(t, err)
143+ assert.Contains(t, err.Error(), "save error")
144+ mock.AssertExpectationsForObjects(t, mockProvider, mockSaver)
145+ })
146+
147+ t.Run("returns error when saver is nil", func(t *testing.T) {
148+ // Arrange
149+ uc := usecase.NewSetKeybindingUseCase(nil, nil)
150+ req := port.SetKeybindingRequest{
151+ Mode: "pane",
152+ Action: "split-right",
153+ Keys: []string{"r"},
154+ }
155+
156+ // Act
157+ _, err := uc.Execute(context.Background(), req)
158+
159+ // Assert
160+ require.Error(t, err)
161+ assert.Contains(t, err.Error(), "saver is nil")
162+ })
163+
164+ t.Run("returns error when mode is empty", func(t *testing.T) {
165+ // Arrange
166+ mockProvider := mocks.NewMockKeybindingsProvider(t)
167+ mockSaver := mocks.NewMockKeybindingsSaver(t)
168+ req := port.SetKeybindingRequest{
169+ Mode: "",
170+ Action: "split-right",
171+ Keys: []string{"r"},
172+ }
173+
174+ uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver)
175+
176+ // Act
177+ _, err := uc.Execute(context.Background(), req)
178+
179+ // Assert
180+ require.Error(t, err)
181+ assert.Contains(t, err.Error(), "mode is required")
182+ })
183+
184+ t.Run("returns error when action is empty", func(t *testing.T) {
185+ // Arrange
186+ mockProvider := mocks.NewMockKeybindingsProvider(t)
187+ mockSaver := mocks.NewMockKeybindingsSaver(t)
188+ req := port.SetKeybindingRequest{
189+ Mode: "pane",
190+ Action: "",
191+ Keys: []string{"r"},
192+ }
193+
194+ uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver)
195+
196+ // Act
197+ _, err := uc.Execute(context.Background(), req)
198+
199+ // Assert
200+ require.Error(t, err)
201+ assert.Contains(t, err.Error(), "action is required")
202+ })
203+
204+ t.Run("returns error for invalid mode", func(t *testing.T) {
205+ // Arrange
206+ mockProvider := mocks.NewMockKeybindingsProvider(t)
207+ mockSaver := mocks.NewMockKeybindingsSaver(t)
208+ req := port.SetKeybindingRequest{
209+ Mode: "invalid",
210+ Action: "split-right",
211+ Keys: []string{"r"},
212+ }
213+
214+ uc := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver)
215+
216+ // Act
217+ _, err := uc.Execute(context.Background(), req)
218+
219+ // Assert
220+ require.Error(t, err)
221+ assert.Contains(t, err.Error(), "invalid mode")
222+ })
223+}
224+
225+func TestResetKeybindingUseCase_Execute(t *testing.T) {
226+ t.Run("successfully resets keybinding", func(t *testing.T) {
227+ // Arrange
228+ mockSaver := mocks.NewMockKeybindingsSaver(t)
229+ req := port.ResetKeybindingRequest{
230+ Mode: "pane",
231+ Action: "split-right",
232+ }
233+ mockSaver.EXPECT().ResetKeybinding(mock.Anything, req).Return(nil)
234+
235+ uc := usecase.NewResetKeybindingUseCase(mockSaver)
236+
237+ // Act
238+ err := uc.Execute(context.Background(), req)
239+
240+ // Assert
241+ require.NoError(t, err)
242+ mock.AssertExpectationsForObjects(t, mockSaver)
243+ })
244+
245+ t.Run("returns error when saver fails", func(t *testing.T) {
246+ // Arrange
247+ mockSaver := mocks.NewMockKeybindingsSaver(t)
248+ req := port.ResetKeybindingRequest{
249+ Mode: "pane",
250+ Action: "split-right",
251+ }
252+ mockSaver.EXPECT().ResetKeybinding(mock.Anything, req).Return(errors.New("reset error"))
253+
254+ uc := usecase.NewResetKeybindingUseCase(mockSaver)
255+
256+ // Act
257+ err := uc.Execute(context.Background(), req)
258+
259+ // Assert
260+ require.Error(t, err)
261+ assert.Contains(t, err.Error(), "reset error")
262+ mock.AssertExpectationsForObjects(t, mockSaver)
263+ })
264+
265+ t.Run("returns error when saver is nil", func(t *testing.T) {
266+ // Arrange
267+ uc := usecase.NewResetKeybindingUseCase(nil)
268+ req := port.ResetKeybindingRequest{
269+ Mode: "pane",
270+ Action: "split-right",
271+ }
272+
273+ // Act
274+ err := uc.Execute(context.Background(), req)
275+
276+ // Assert
277+ require.Error(t, err)
278+ assert.Contains(t, err.Error(), "saver is nil")
279+ })
280+
281+ t.Run("returns error when mode is empty", func(t *testing.T) {
282+ // Arrange
283+ mockSaver := mocks.NewMockKeybindingsSaver(t)
284+ req := port.ResetKeybindingRequest{
285+ Mode: "",
286+ Action: "split-right",
287+ }
288+
289+ uc := usecase.NewResetKeybindingUseCase(mockSaver)
290+
291+ // Act
292+ err := uc.Execute(context.Background(), req)
293+
294+ // Assert
295+ require.Error(t, err)
296+ assert.Contains(t, err.Error(), "mode is required")
297+ })
298+}
299+
300+func TestResetAllKeybindingsUseCase_Execute(t *testing.T) {
301+ t.Run("successfully resets all keybindings", func(t *testing.T) {
302+ // Arrange
303+ mockSaver := mocks.NewMockKeybindingsSaver(t)
304+ mockSaver.EXPECT().ResetAllKeybindings(mock.Anything).Return(nil)
305+
306+ uc := usecase.NewResetAllKeybindingsUseCase(mockSaver)
307+
308+ // Act
309+ err := uc.Execute(context.Background())
310+
311+ // Assert
312+ require.NoError(t, err)
313+ mock.AssertExpectationsForObjects(t, mockSaver)
314+ })
315+
316+ t.Run("returns error when saver fails", func(t *testing.T) {
317+ // Arrange
318+ mockSaver := mocks.NewMockKeybindingsSaver(t)
319+ mockSaver.EXPECT().ResetAllKeybindings(mock.Anything).Return(errors.New("reset all error"))
320+
321+ uc := usecase.NewResetAllKeybindingsUseCase(mockSaver)
322+
323+ // Act
324+ err := uc.Execute(context.Background())
325+
326+ // Assert
327+ require.Error(t, err)
328+ assert.Contains(t, err.Error(), "reset all error")
329+ mock.AssertExpectationsForObjects(t, mockSaver)
330+ })
331+
332+ t.Run("returns error when saver is nil", func(t *testing.T) {
333+ // Arrange
334+ uc := usecase.NewResetAllKeybindingsUseCase(nil)
335+
336+ // Act
337+ err := uc.Execute(context.Background())
338+
339+ // Assert
340+ require.Error(t, err)
341+ assert.Contains(t, err.Error(), "saver is nil")
342+ })
343+}
A internal/application/usecase/transform_legacy_config.go +18 -0
@@ -0,0 +1,18 @@
1+package usecase
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+// TransformLegacyConfigUseCase handles legacy config format transformation.
6+type TransformLegacyConfigUseCase struct {
7+ transformer port.ConfigTransformer
8+}
9+
10+// NewTransformLegacyConfigUseCase creates a new use case instance.
11+func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *TransformLegacyConfigUseCase {
12+ return &TransformLegacyConfigUseCase{transformer: transformer}
13+}
14+
15+// Execute transforms legacy config format in place.
16+func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) {
17+ uc.transformer.TransformLegacyActions(rawConfig)
18+}
A internal/application/usecase/transform_legacy_config_test.go +41 -0
@@ -0,0 +1,41 @@
1+package usecase
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port/mocks"
7+ "github.com/stretchr/testify/mock"
8+)
9+
10+func TestTransformLegacyConfigUseCase_Execute(t *testing.T) {
11+ mockTransformer := mocks.NewMockConfigTransformer(t)
12+
13+ rawConfig := map[string]any{"test": "data"}
14+
15+ mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return()
16+
17+ uc := NewTransformLegacyConfigUseCase(mockTransformer)
18+ uc.Execute(rawConfig)
19+
20+ // AssertExpectations is called automatically via t.Cleanup
21+}
22+
23+func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) {
24+ mockTransformer := mocks.NewMockConfigTransformer(t)
25+
26+ rawConfig := map[string]any{
27+ "workspace": map[string]any{
28+ "pane_mode": map[string]any{
29+ "actions": map[string]any{
30+ "focus-left": []any{"h"},
31+ },
32+ },
33+ },
34+ }
35+
36+ // Verify the transformer receives the exact rawConfig
37+ mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return()
38+
39+ uc := NewTransformLegacyConfigUseCase(mockTransformer)
40+ uc.Execute(rawConfig)
41+}
M internal/infrastructure/config/defaults.go +53 -52
@@ -161,40 +161,40 @@ func DefaultConfig() *Config {
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 +285 -0
@@ -0,0 +1,285 @@
1+package config
2+
3+import (
4+ "context"
5+ "reflect"
6+ "sort"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+const modeGlobal = "global"
13+
14+// KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver.
15+type KeybindingsGateway struct {
16+ mgr *Manager
17+}
18+
19+// NewKeybindingsGateway creates a new KeybindingsGateway.
20+func NewKeybindingsGateway(mgr *Manager) *KeybindingsGateway {
21+ return &KeybindingsGateway{mgr: mgr}
22+}
23+
24+// GetKeybindings returns all keybindings grouped by mode.
25+func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
26+ log := logging.FromContext(ctx)
27+ cfg := g.mgr.Get()
28+ defaults := DefaultConfig()
29+ log.Debug().Msg("keybindings gateway: fetching keybindings")
30+
31+ groups := []port.KeybindingGroup{
32+ g.buildGlobalGroup(cfg, defaults),
33+ g.buildPaneModeGroup(cfg, defaults),
34+ g.buildTabModeGroup(cfg, defaults),
35+ g.buildResizeModeGroup(cfg, defaults),
36+ g.buildSessionModeGroup(cfg, defaults),
37+ }
38+
39+ return port.KeybindingsConfig{Groups: groups}, nil
40+}
41+
42+// GetDefaultKeybindings returns the default keybindings configuration.
43+func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
44+ log := logging.FromContext(ctx)
45+ defaults := DefaultConfig()
46+
47+ groups := []port.KeybindingGroup{
48+ g.buildGlobalGroup(defaults, defaults),
49+ g.buildPaneModeGroup(defaults, defaults),
50+ g.buildTabModeGroup(defaults, defaults),
51+ g.buildResizeModeGroup(defaults, defaults),
52+ g.buildSessionModeGroup(defaults, defaults),
53+ }
54+
55+ log.Debug().Int("groups", len(groups)).Msg("keybindings gateway: returning default keybindings")
56+ return port.KeybindingsConfig{Groups: groups}, nil
57+}
58+
59+// CheckConflicts scans all modes for keybinding conflicts.
60+func (g *KeybindingsGateway) CheckConflicts(ctx context.Context, mode, action string, keys []string) ([]port.KeybindingConflict, error) {
61+ log := logging.FromContext(ctx)
62+ cfg := g.mgr.Get()
63+
64+ conflicts := g.checkConflicts(cfg, mode, action, keys)
65+ if len(conflicts) > 0 {
66+ log.Warn().Int("conflicts", len(conflicts)).Msg("keybindings gateway: conflicts detected")
67+ }
68+
69+ return conflicts, nil
70+}
71+
72+// SetKeybinding updates a single keybinding.
73+func (g *KeybindingsGateway) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) error {
74+ log := logging.FromContext(ctx)
75+ log.Debug().Str("mode", req.Mode).Str("action", req.Action).Msg("keybindings gateway: setting keybinding")
76+ cfg := g.mgr.Get()
77+ g.updateKeybinding(cfg, req)
78+ return g.mgr.Save(cfg)
79+}
80+
81+// ResetKeybinding resets a keybinding to its default value.
82+func (g *KeybindingsGateway) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
83+ log := logging.FromContext(ctx)
84+ log.Info().Str("mode", req.Mode).Str("action", req.Action).Msg("keybindings gateway: resetting keybinding to default")
85+
86+ defaults := DefaultConfig()
87+ defaultKeys := g.getDefaultKeys(defaults, req.Mode, req.Action)
88+
89+ setReq := port.SetKeybindingRequest{
90+ Mode: req.Mode,
91+ Action: req.Action,
92+ Keys: defaultKeys,
93+ }
94+
95+ cfg := g.mgr.Get()
96+ g.updateKeybinding(cfg, setReq)
97+
98+ return g.mgr.Save(cfg)
99+}
100+
101+// ResetAllKeybindings resets all keybindings to their default values.
102+func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error {
103+ log := logging.FromContext(ctx)
104+ log.Info().Msg("keybindings gateway: resetting all keybindings to defaults")
105+
106+ cfg := g.mgr.Get()
107+ defaults := DefaultConfig()
108+
109+ cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions
110+ cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions
111+ cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions
112+ cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts
113+ cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions
114+
115+ return g.mgr.Save(cfg)
116+}
117+
118+// buildGlobalGroup builds the global shortcuts group.
119+func (g *KeybindingsGateway) buildGlobalGroup(cfg, defaults *Config) port.KeybindingGroup {
120+ return port.KeybindingGroup{
121+ Mode: modeGlobal,
122+ DisplayName: "Global Shortcuts",
123+ Bindings: g.buildModeBindings(cfg.Workspace.Shortcuts.Actions, defaults.Workspace.Shortcuts.Actions),
124+ }
125+}
126+
127+// buildPaneModeGroup builds the pane mode group.
128+func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup {
129+ return port.KeybindingGroup{
130+ Mode: "pane",
131+ DisplayName: "Pane Mode",
132+ Bindings: g.buildModeBindings(cfg.Workspace.PaneMode.Actions, defaults.Workspace.PaneMode.Actions),
133+ Activation: cfg.Workspace.PaneMode.ActivationShortcut,
134+ }
135+}
136+
137+// buildTabModeGroup builds the tab mode group.
138+func (g *KeybindingsGateway) buildTabModeGroup(cfg, defaults *Config) port.KeybindingGroup {
139+ return port.KeybindingGroup{
140+ Mode: "tab",
141+ DisplayName: "Tab Mode",
142+ Bindings: g.buildModeBindings(cfg.Workspace.TabMode.Actions, defaults.Workspace.TabMode.Actions),
143+ Activation: cfg.Workspace.TabMode.ActivationShortcut,
144+ }
145+}
146+
147+// buildResizeModeGroup builds the resize mode group.
148+func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup {
149+ return port.KeybindingGroup{
150+ Mode: "resize",
151+ DisplayName: "Resize Mode",
152+ Bindings: g.buildModeBindings(cfg.Workspace.ResizeMode.Actions, defaults.Workspace.ResizeMode.Actions),
153+ Activation: cfg.Workspace.ResizeMode.ActivationShortcut,
154+ }
155+}
156+
157+// buildSessionModeGroup builds the session mode group.
158+func (g *KeybindingsGateway) buildSessionModeGroup(cfg, defaults *Config) port.KeybindingGroup {
159+ return port.KeybindingGroup{
160+ Mode: "session",
161+ DisplayName: "Session Mode",
162+ Bindings: g.buildModeBindings(cfg.Session.SessionMode.Actions, defaults.Session.SessionMode.Actions),
163+ Activation: cfg.Session.SessionMode.ActivationShortcut,
164+ }
165+}
166+
167+// buildModeBindings builds keybinding entries for a modal mode.
168+func (*KeybindingsGateway) buildModeBindings(actions, defaultActions map[string]ActionBinding) []port.KeybindingEntry {
169+ var entries []port.KeybindingEntry
170+
171+ actionNames := make([]string, 0, len(actions))
172+ for action := range actions {
173+ actionNames = append(actionNames, action)
174+ }
175+ sort.Strings(actionNames)
176+
177+ for _, action := range actionNames {
178+ binding := actions[action]
179+ defaultBinding := defaultActions[action]
180+
181+ entries = append(entries, port.KeybindingEntry{
182+ Action: action,
183+ Description: binding.Desc,
184+ Keys: binding.Keys,
185+ DefaultKeys: defaultBinding.Keys,
186+ IsCustom: !reflect.DeepEqual(binding.Keys, defaultBinding.Keys),
187+ })
188+ }
189+
190+ return entries
191+}
192+
193+// updateKeybinding updates a keybinding in the config.
194+func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingRequest) {
195+ switch req.Mode {
196+ case modeGlobal:
197+ if existing, ok := cfg.Workspace.Shortcuts.Actions[req.Action]; ok {
198+ existing.Keys = req.Keys
199+ cfg.Workspace.Shortcuts.Actions[req.Action] = existing
200+ }
201+ case "pane":
202+ if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok {
203+ existing.Keys = req.Keys
204+ cfg.Workspace.PaneMode.Actions[req.Action] = existing
205+ }
206+ case "tab":
207+ if existing, ok := cfg.Workspace.TabMode.Actions[req.Action]; ok {
208+ existing.Keys = req.Keys
209+ cfg.Workspace.TabMode.Actions[req.Action] = existing
210+ }
211+ case "resize":
212+ if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok {
213+ existing.Keys = req.Keys
214+ cfg.Workspace.ResizeMode.Actions[req.Action] = existing
215+ }
216+ case "session":
217+ if existing, ok := cfg.Session.SessionMode.Actions[req.Action]; ok {
218+ existing.Keys = req.Keys
219+ cfg.Session.SessionMode.Actions[req.Action] = existing
220+ }
221+ }
222+}
223+
224+// getDefaultKeys returns the default keys for an action.
225+func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string) []string {
226+ switch mode {
227+ case modeGlobal:
228+ if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok {
229+ return binding.Keys
230+ }
231+ case "pane":
232+ return defaults.Workspace.PaneMode.Actions[action].Keys
233+ case "tab":
234+ return defaults.Workspace.TabMode.Actions[action].Keys
235+ case "resize":
236+ return defaults.Workspace.ResizeMode.Actions[action].Keys
237+ case "session":
238+ return defaults.Session.SessionMode.Actions[action].Keys
239+ }
240+ return nil
241+}
242+
243+// checkConflicts scans all modes for keybinding conflicts.
244+func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction string, newKeys []string) []port.KeybindingConflict {
245+ var conflicts []port.KeybindingConflict
246+
247+ type binding struct {
248+ mode string
249+ action string
250+ }
251+ keyMap := make(map[string][]binding)
252+
253+ addBindings := func(mode string, actions map[string]ActionBinding) {
254+ for action, ab := range actions {
255+ for _, key := range ab.Keys {
256+ keyMap[key] = append(keyMap[key], binding{mode: mode, action: action})
257+ }
258+ }
259+ }
260+
261+ addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
262+ addBindings("pane", cfg.Workspace.PaneMode.Actions)
263+ addBindings("tab", cfg.Workspace.TabMode.Actions)
264+ addBindings("resize", cfg.Workspace.ResizeMode.Actions)
265+ addBindings("session", cfg.Session.SessionMode.Actions)
266+
267+ for _, newKey := range newKeys {
268+ if existing, ok := keyMap[newKey]; ok {
269+ for _, b := range existing {
270+ if b.mode == targetMode && b.action == targetAction {
271+ continue
272+ }
273+ if targetMode == modeGlobal || b.mode == modeGlobal || b.mode == targetMode {
274+ conflicts = append(conflicts, port.KeybindingConflict{
275+ ConflictingAction: b.action,
276+ ConflictingMode: b.mode,
277+ Key: newKey,
278+ })
279+ }
280+ }
281+ }
282+ }
283+
284+ return conflicts
285+}
M internal/infrastructure/config/loader.go +61 -50
@@ -17,11 +17,12 @@ import (
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 +38 -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,53 @@ 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+ // Skip reload if this was triggered by our own Save() - the in-memory
28+ // config is already correct and viper may have stale cached data
29+ if m.skipNextReload {
30+ log.Debug().Msg("skipping reload (triggered by own Save)")
31+ m.skipNextReload = false
32+ // Still need to sync viper's internal state with the file we just wrote
33+ if err := m.viper.ReadInConfig(); err != nil {
34+ log.Warn().Err(err).Msg("failed to sync viper config after Save")
35+ // Continue anyway - in-memory config is correct, just viper internal state is off
36+ }
37+ m.notifyCallbacksLocked()
2438 return
2539 }
2640
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)
41+ log.Debug().Msg("reloading config from external change")
42+ if err := m.reload(); err != nil {
43+ m.mu.Unlock()
44+ log.Warn().Err(err).Msg("failed to reload config")
45+ return
3646 }
47+ m.notifyCallbacksLocked()
3748 })
3849
3950 m.watching = true
4051 return nil
4152 }
4253
54+// notifyCallbacksLocked copies callbacks and config, releases lock, then notifies.
55+// Must be called with m.mu held for write. Releases the lock before calling callbacks.
56+func (m *Manager) notifyCallbacksLocked() {
57+ config := m.config
58+ callbacks := make([]func(*Config), len(m.callbacks))
59+ copy(callbacks, m.callbacks)
60+ m.mu.Unlock()
61+
62+ for _, callback := range callbacks {
63+ callback(config)
64+ }
65+}
66+
4367 // OnConfigChange registers a callback function to be called when config changes.
4468 func (m *Manager) OnConfigChange(callback func(*Config)) {
4569 m.mu.Lock()
A internal/infrastructure/config/writer.go +177 -0
@@ -0,0 +1,177 @@
1+package config
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "sort"
10+ "strings"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/pelletier/go-toml/v2"
14+)
15+
16+// WriteConfigOrdered writes the configuration to disk with consistent ordering.
17+// Uses atomic write (temp file + rename) to prevent race conditions with file watchers.
18+func WriteConfigOrdered(cfg *Config, path string) error {
19+ if cfg == nil {
20+ return fmt.Errorf("config is nil")
21+ }
22+
23+ content, err := encodeConfigToTOML(cfg)
24+ if err != nil {
25+ return err
26+ }
27+
28+ return atomicWriteFile(path, content, filePerm)
29+}
30+
31+// encodeConfigToTOML encodes the config struct to sorted TOML content.
32+func encodeConfigToTOML(cfg *Config) (string, error) {
33+ var buf bytes.Buffer
34+ enc := toml.NewEncoder(&buf)
35+ enc.SetIndentTables(true)
36+
37+ if err := enc.Encode(cfg); err != nil {
38+ return "", fmt.Errorf("failed to encode config: %w", err)
39+ }
40+
41+ return sortTOMLSections(buf.String()), nil
42+}
43+
44+// atomicWriteFile writes content to a file atomically using temp file + rename.
45+// This ensures file watchers only see complete content, not partial writes.
46+func atomicWriteFile(path, content string, perm os.FileMode) error {
47+ dir := filepath.Dir(path)
48+ tmpFile, err := os.CreateTemp(dir, ".config-*.tmp")
49+ if err != nil {
50+ return fmt.Errorf("failed to create temp file: %w", err)
51+ }
52+ tmpPath := tmpFile.Name()
53+
54+ // Cleanup on error
55+ success := false
56+ defer func() {
57+ if !success {
58+ if removeErr := os.Remove(tmpPath); removeErr != nil {
59+ log := logging.NewFromEnv()
60+ log.Warn().Err(removeErr).Str("path", tmpPath).Msg("failed to cleanup temp config file")
61+ }
62+ }
63+ }()
64+
65+ if err := writeAndSync(tmpFile, content); err != nil {
66+ return err
67+ }
68+
69+ if err := os.Chmod(tmpPath, perm); err != nil {
70+ return fmt.Errorf("failed to set file permissions: %w", err)
71+ }
72+
73+ if err := os.Rename(tmpPath, path); err != nil {
74+ return fmt.Errorf("failed to rename temp file: %w", err)
75+ }
76+
77+ success = true
78+ return nil
79+}
80+
81+// writeAndSync writes content to file and syncs to disk.
82+func writeAndSync(f *os.File, content string) (err error) {
83+ // Ensure file is closed, capturing close error if no prior error
84+ defer func() {
85+ closeErr := f.Close()
86+ if err == nil && closeErr != nil {
87+ err = fmt.Errorf("failed to close file: %w", closeErr)
88+ }
89+ }()
90+
91+ if _, err = f.WriteString(content); err != nil {
92+ return fmt.Errorf("failed to write file: %w", err)
93+ }
94+
95+ if err = f.Sync(); err != nil {
96+ return fmt.Errorf("failed to sync file: %w", err)
97+ }
98+
99+ return nil
100+}
101+
102+// tomlSection represents a parsed TOML section with its header and content lines.
103+type tomlSection struct {
104+ header string // Section name without brackets (e.g., "workspace.shortcuts")
105+ lines []string // All lines belonging to this section, including the header
106+}
107+
108+// sortTOMLSections sorts TOML content so sections appear in alphabetical order.
109+func sortTOMLSections(content string) string {
110+ lines := strings.Split(content, "\n")
111+ preamble, sections := parseTOMLSections(lines)
112+
113+ sort.Slice(sections, func(i, j int) bool {
114+ return sections[i].header < sections[j].header
115+ })
116+
117+ return buildTOMLOutput(preamble, sections)
118+}
119+
120+// parseTOMLSections parses TOML lines into preamble (top-level keys) and sections.
121+func parseTOMLSections(lines []string) (preamble []string, sections []tomlSection) {
122+ sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`)
123+ var current *tomlSection
124+
125+ for _, line := range lines {
126+ if match := sectionRegex.FindStringSubmatch(line); match != nil {
127+ if current != nil {
128+ sections = append(sections, *current)
129+ }
130+ current = &tomlSection{
131+ header: match[2],
132+ lines: []string{line},
133+ }
134+ } else if current != nil {
135+ current.lines = append(current.lines, line)
136+ } else {
137+ preamble = append(preamble, line)
138+ }
139+ }
140+
141+ if current != nil {
142+ sections = append(sections, *current)
143+ }
144+
145+ return preamble, sections
146+}
147+
148+// buildTOMLOutput reconstructs TOML content from preamble and sorted sections.
149+func buildTOMLOutput(preamble []string, sections []tomlSection) string {
150+ var result strings.Builder
151+
152+ for _, line := range preamble {
153+ result.WriteString(line)
154+ result.WriteString("\n")
155+ }
156+
157+ for i, sec := range sections {
158+ if i > 0 || len(preamble) > 0 {
159+ content := result.String()
160+ if !strings.HasSuffix(content, "\n\n") && content != "" {
161+ result.WriteString("\n")
162+ }
163+ }
164+
165+ for _, line := range sec.lines {
166+ result.WriteString(line)
167+ result.WriteString("\n")
168+ }
169+ }
170+
171+ output := strings.TrimRight(result.String(), "\n")
172+ if output != "" {
173+ output += "\n"
174+ }
175+
176+ return output
177+}
A internal/infrastructure/config/writer_test.go +98 -0
@@ -0,0 +1,98 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestWriteConfigOrdered(t *testing.T) {
11+ // Create a temp directory for the test
12+ tmpDir := t.TempDir()
13+ configPath := filepath.Join(tmpDir, "config.toml")
14+
15+ // Get default config
16+ cfg := DefaultConfig()
17+
18+ // Write it
19+ err := WriteConfigOrdered(cfg, configPath)
20+ if err != nil {
21+ t.Fatalf("WriteConfigOrdered failed: %v", err)
22+ }
23+
24+ // Read back
25+ content, err := os.ReadFile(configPath)
26+ if err != nil {
27+ t.Fatalf("Failed to read written config: %v", err)
28+ }
29+
30+ // Verify sections are in alphabetical order
31+ lines := strings.Split(string(content), "\n")
32+ var sections []string
33+ for _, line := range lines {
34+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
35+ sections = append(sections, line)
36+ }
37+ }
38+
39+ // Check that sections are sorted
40+ for i := 1; i < len(sections); i++ {
41+ if sections[i-1] > sections[i] {
42+ t.Errorf("Sections not sorted: %s > %s", sections[i-1], sections[i])
43+ }
44+ }
45+
46+ t.Logf("Found %d sections, all sorted", len(sections))
47+
48+ // Print first 50 lines for debugging
49+ t.Logf("First 50 lines of output:\n%s", strings.Join(lines[:min(50, len(lines))], "\n"))
50+}
51+
52+func TestSortTOMLSections(t *testing.T) {
53+ input := `default_search_engine = 'https://example.com'
54+
55+[workspace]
56+new_pane_url = 'about:blank'
57+
58+[appearance]
59+color_scheme = 'default'
60+
61+[workspace.pane_mode]
62+timeout_ms = 3000
63+
64+[appearance.dark_palette]
65+background = '#000'
66+`
67+
68+ result := sortTOMLSections(input)
69+
70+ // Extract section order
71+ lines := strings.Split(result, "\n")
72+ var sections []string
73+ for _, line := range lines {
74+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
75+ sections = append(sections, line)
76+ }
77+ }
78+
79+ // Expected order: appearance, appearance.dark_palette, workspace, workspace.pane_mode
80+ expected := []string{
81+ "[appearance]",
82+ "[appearance.dark_palette]",
83+ "[workspace]",
84+ "[workspace.pane_mode]",
85+ }
86+
87+ if len(sections) != len(expected) {
88+ t.Fatalf("Expected %d sections, got %d: %v", len(expected), len(sections), sections)
89+ }
90+
91+ for i, exp := range expected {
92+ if sections[i] != exp {
93+ t.Errorf("Section %d: expected %s, got %s", i, exp, sections[i])
94+ }
95+ }
96+
97+ t.Logf("Sorted output:\n%s", result)
98+}
A internal/infrastructure/webkit/handlers/keybindings.go +144 -0
@@ -0,0 +1,144 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// KeybindingsHandler handles keybinding-related messages.
15+type KeybindingsHandler struct {
16+ getUC *usecase.GetKeybindingsUseCase
17+ setUC *usecase.SetKeybindingUseCase
18+ resetUC *usecase.ResetKeybindingUseCase
19+ resetAllUC *usecase.ResetAllKeybindingsUseCase
20+}
21+
22+// NewKeybindingsHandler creates a new KeybindingsHandler.
23+func NewKeybindingsHandler(
24+ getUC *usecase.GetKeybindingsUseCase,
25+ setUC *usecase.SetKeybindingUseCase,
26+ resetUC *usecase.ResetKeybindingUseCase,
27+ resetAllUC *usecase.ResetAllKeybindingsUseCase,
28+) *KeybindingsHandler {
29+ return &KeybindingsHandler{
30+ getUC: getUC,
31+ setUC: setUC,
32+ resetUC: resetUC,
33+ resetAllUC: resetAllUC,
34+ }
35+}
36+
37+// HandleGetKeybindings returns all keybindings grouped by mode.
38+func (h *KeybindingsHandler) HandleGetKeybindings(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
39+ return h.getUC.Execute(ctx)
40+}
41+
42+// HandleSetKeybinding updates a single keybinding.
43+func (h *KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
44+ log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
45+ log.Debug().RawJSON("payload", payload).Msg("HandleSetKeybinding called")
46+
47+ var req port.SetKeybindingRequest
48+ if err := json.Unmarshal(payload, &req); err != nil {
49+ log.Error().Err(err).RawJSON("payload", payload).Msg("failed to unmarshal request")
50+ return nil, fmt.Errorf("invalid request: %w", err)
51+ }
52+
53+ resp, err := h.setUC.Execute(ctx, req)
54+ if err != nil {
55+ return nil, err
56+ }
57+
58+ return map[string]any{
59+ "status": "success",
60+ "conflicts": resp.Conflicts,
61+ }, nil
62+}
63+
64+// HandleResetKeybinding resets a keybinding to default.
65+func (h *KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
66+ log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
67+
68+ var req port.ResetKeybindingRequest
69+ if err := json.Unmarshal(payload, &req); err != nil {
70+ return nil, fmt.Errorf("invalid request: %w", err)
71+ }
72+
73+ log.Info().Str("mode", req.Mode).Str("action", req.Action).Msg("resetting keybinding to default")
74+
75+ if err := h.resetUC.Execute(ctx, req); err != nil {
76+ return nil, err
77+ }
78+
79+ return map[string]any{"status": "success"}, nil
80+}
81+
82+// HandleResetAllKeybindings resets all keybindings to defaults.
83+func (h *KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
84+ log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
85+ log.Info().Msg("resetting all keybindings to defaults")
86+
87+ if err := h.resetAllUC.Execute(ctx); err != nil {
88+ return nil, err
89+ }
90+
91+ return map[string]any{"status": "success"}, nil
92+}
93+
94+// RegisterKeybindingsHandlers registers keybindings handlers with the router.
95+func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRouter, handler *KeybindingsHandler) error {
96+ log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()
97+
98+ // Get all keybindings
99+ if err := router.RegisterHandlerWithCallbacks(
100+ "get_keybindings",
101+ "__dumber_keybindings_loaded",
102+ "__dumber_keybindings_error",
103+ "",
104+ webkit.MessageHandlerFunc(handler.HandleGetKeybindings),
105+ ); err != nil {
106+ return err
107+ }
108+
109+ // Set a single keybinding
110+ if err := router.RegisterHandlerWithCallbacks(
111+ "set_keybinding",
112+ "__dumber_keybinding_set",
113+ "__dumber_keybinding_set_error",
114+ "",
115+ webkit.MessageHandlerFunc(handler.HandleSetKeybinding),
116+ ); err != nil {
117+ return err
118+ }
119+
120+ // Reset a single keybinding
121+ if err := router.RegisterHandlerWithCallbacks(
122+ "reset_keybinding",
123+ "__dumber_keybinding_reset",
124+ "__dumber_keybinding_reset_error",
125+ "",
126+ webkit.MessageHandlerFunc(handler.HandleResetKeybinding),
127+ ); err != nil {
128+ return err
129+ }
130+
131+ // Reset all keybindings
132+ if err := router.RegisterHandlerWithCallbacks(
133+ "reset_all_keybindings",
134+ "__dumber_keybindings_reset_all",
135+ "__dumber_keybindings_reset_all_error",
136+ "",
137+ webkit.MessageHandlerFunc(handler.HandleResetAllKeybindings),
138+ ); err != nil {
139+ return err
140+ }
141+
142+ log.Info().Msg("registered keybindings handlers")
143+ return nil
144+}
M internal/infrastructure/webkit/handlers/register.go +27 -0
@@ -2,6 +2,7 @@ package handlers
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/ui/app.go +5 -1
@@ -1946,11 +1946,15 @@ func (a *App) initConfigWatcher(ctx context.Context) {
19461946 return
19471947 }
19481948
1949- // Only appearance is hot-reloaded for now.
1949+ // Hot-reload appearance and keybindings on config change.
19501950 a.configManager.OnConfigChange(func(newCfg *config.Config) {
19511951 cfgCopy := newCfg
19521952 cb := glib.SourceFunc(func(_ uintptr) bool {
19531953 a.applyAppearanceConfig(ctx, cfgCopy)
1954+ // Reload keyboard shortcuts
1955+ if a.keyboardHandler != nil {
1956+ a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy)
1957+ }
19541958 return false
19551959 })
19561960 glib.IdleAdd(&cb, 0)
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 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
@@ -25,6 +25,8 @@
2525 "@typescript-eslint/parser": "^7.0.0",
2626 "autoprefixer": "^10.4.20",
2727 "eslint": "^8.57.0",
28+ "eslint-plugin-svelte": "^3.14.0",
29+ "globals": "^17.1.0",
2830 "postcss": "^8.4.47",
2931 "svelte": "^5.0.0",
3032 "svelte-awesome-color-picker": "^4.1.0",
@@ -505,6 +507,22 @@
505507 "concat-map": "0.0.1"
506508 }
507509 },
510+ "node_modules/@eslint/eslintrc/node_modules/globals": {
511+ "version": "13.24.0",
512+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
513+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
514+ "dev": true,
515+ "license": "MIT",
516+ "dependencies": {
517+ "type-fest": "^0.20.2"
518+ },
519+ "engines": {
520+ "node": ">=8"
521+ },
522+ "funding": {
523+ "url": "https://github.com/sponsors/sindresorhus"
524+ }
525+ },
508526 "node_modules/@eslint/eslintrc/node_modules/minimatch": {
509527 "version": "3.1.2",
510528 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2356,6 +2374,19 @@
23562374 "node": ">= 8"
23572375 }
23582376 },
2377+ "node_modules/cssesc": {
2378+ "version": "3.0.0",
2379+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
2380+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
2381+ "dev": true,
2382+ "license": "MIT",
2383+ "bin": {
2384+ "cssesc": "bin/cssesc"
2385+ },
2386+ "engines": {
2387+ "node": ">=4"
2388+ }
2389+ },
23592390 "node_modules/debug": {
23602391 "version": "4.4.3",
23612392 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2583,6 +2614,53 @@
25832614 "url": "https://opencollective.com/eslint"
25842615 }
25852616 },
2617+ "node_modules/eslint-plugin-svelte": {
2618+ "version": "3.14.0",
2619+ "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.14.0.tgz",
2620+ "integrity": "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==",
2621+ "dev": true,
2622+ "license": "MIT",
2623+ "dependencies": {
2624+ "@eslint-community/eslint-utils": "^4.6.1",
2625+ "@jridgewell/sourcemap-codec": "^1.5.0",
2626+ "esutils": "^2.0.3",
2627+ "globals": "^16.0.0",
2628+ "known-css-properties": "^0.37.0",
2629+ "postcss": "^8.4.49",
2630+ "postcss-load-config": "^3.1.4",
2631+ "postcss-safe-parser": "^7.0.0",
2632+ "semver": "^7.6.3",
2633+ "svelte-eslint-parser": "^1.4.0"
2634+ },
2635+ "engines": {
2636+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2637+ },
2638+ "funding": {
2639+ "url": "https://github.com/sponsors/ota-meshi"
2640+ },
2641+ "peerDependencies": {
2642+ "eslint": "^8.57.1 || ^9.0.0",
2643+ "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
2644+ },
2645+ "peerDependenciesMeta": {
2646+ "svelte": {
2647+ "optional": true
2648+ }
2649+ }
2650+ },
2651+ "node_modules/eslint-plugin-svelte/node_modules/globals": {
2652+ "version": "16.5.0",
2653+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
2654+ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
2655+ "dev": true,
2656+ "license": "MIT",
2657+ "engines": {
2658+ "node": ">=18"
2659+ },
2660+ "funding": {
2661+ "url": "https://github.com/sponsors/sindresorhus"
2662+ }
2663+ },
25862664 "node_modules/eslint-scope": {
25872665 "version": "7.2.2",
25882666 "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@@ -2624,6 +2702,22 @@
26242702 "concat-map": "0.0.1"
26252703 }
26262704 },
2705+ "node_modules/eslint/node_modules/globals": {
2706+ "version": "13.24.0",
2707+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
2708+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
2709+ "dev": true,
2710+ "license": "MIT",
2711+ "dependencies": {
2712+ "type-fest": "^0.20.2"
2713+ },
2714+ "engines": {
2715+ "node": ">=8"
2716+ },
2717+ "funding": {
2718+ "url": "https://github.com/sponsors/sindresorhus"
2719+ }
2720+ },
26272721 "node_modules/eslint/node_modules/minimatch": {
26282722 "version": "3.1.2",
26292723 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2962,16 +3056,13 @@
29623056 }
29633057 },
29643058 "node_modules/globals": {
2965- "version": "13.24.0",
2966- "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
2967- "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
3059+ "version": "17.1.0",
3060+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.1.0.tgz",
3061+ "integrity": "sha512-8HoIcWI5fCvG5NADj4bDav+er9B9JMj2vyL2pI8D0eismKyUvPLTSs+Ln3wqhwcp306i73iyVnEKx3F6T47TGw==",
29683062 "dev": true,
29693063 "license": "MIT",
2970- "dependencies": {
2971- "type-fest": "^0.20.2"
2972- },
29733064 "engines": {
2974- "node": ">=8"
3065+ "node": ">=18"
29753066 },
29763067 "funding": {
29773068 "url": "https://github.com/sponsors/sindresorhus"
@@ -3207,6 +3298,13 @@
32073298 "node": ">=6"
32083299 }
32093300 },
3301+ "node_modules/known-css-properties": {
3302+ "version": "0.37.0",
3303+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
3304+ "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
3305+ "dev": true,
3306+ "license": "MIT"
3307+ },
32103308 "node_modules/levn": {
32113309 "version": "0.4.1",
32123310 "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3482,6 +3580,16 @@
34823580 "url": "https://opencollective.com/parcel"
34833581 }
34843582 },
3583+ "node_modules/lilconfig": {
3584+ "version": "2.1.0",
3585+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
3586+ "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
3587+ "dev": true,
3588+ "license": "MIT",
3589+ "engines": {
3590+ "node": ">=10"
3591+ }
3592+ },
34853593 "node_modules/locate-character": {
34863594 "version": "3.0.0",
34873595 "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -3865,6 +3973,104 @@
38653973 "node": "^10 || ^12 || >=14"
38663974 }
38673975 },
3976+ "node_modules/postcss-load-config": {
3977+ "version": "3.1.4",
3978+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
3979+ "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
3980+ "dev": true,
3981+ "license": "MIT",
3982+ "dependencies": {
3983+ "lilconfig": "^2.0.5",
3984+ "yaml": "^1.10.2"
3985+ },
3986+ "engines": {
3987+ "node": ">= 10"
3988+ },
3989+ "funding": {
3990+ "type": "opencollective",
3991+ "url": "https://opencollective.com/postcss/"
3992+ },
3993+ "peerDependencies": {
3994+ "postcss": ">=8.0.9",
3995+ "ts-node": ">=9.0.0"
3996+ },
3997+ "peerDependenciesMeta": {
3998+ "postcss": {
3999+ "optional": true
4000+ },
4001+ "ts-node": {
4002+ "optional": true
4003+ }
4004+ }
4005+ },
4006+ "node_modules/postcss-safe-parser": {
4007+ "version": "7.0.1",
4008+ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz",
4009+ "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==",
4010+ "dev": true,
4011+ "funding": [
4012+ {
4013+ "type": "opencollective",
4014+ "url": "https://opencollective.com/postcss/"
4015+ },
4016+ {
4017+ "type": "tidelift",
4018+ "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser"
4019+ },
4020+ {
4021+ "type": "github",
4022+ "url": "https://github.com/sponsors/ai"
4023+ }
4024+ ],
4025+ "license": "MIT",
4026+ "engines": {
4027+ "node": ">=18.0"
4028+ },
4029+ "peerDependencies": {
4030+ "postcss": "^8.4.31"
4031+ }
4032+ },
4033+ "node_modules/postcss-scss": {
4034+ "version": "4.0.9",
4035+ "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz",
4036+ "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==",
4037+ "dev": true,
4038+ "funding": [
4039+ {
4040+ "type": "opencollective",
4041+ "url": "https://opencollective.com/postcss/"
4042+ },
4043+ {
4044+ "type": "tidelift",
4045+ "url": "https://tidelift.com/funding/github/npm/postcss-scss"
4046+ },
4047+ {
4048+ "type": "github",
4049+ "url": "https://github.com/sponsors/ai"
4050+ }
4051+ ],
4052+ "license": "MIT",
4053+ "engines": {
4054+ "node": ">=12.0"
4055+ },
4056+ "peerDependencies": {
4057+ "postcss": "^8.4.29"
4058+ }
4059+ },
4060+ "node_modules/postcss-selector-parser": {
4061+ "version": "7.1.1",
4062+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
4063+ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
4064+ "dev": true,
4065+ "license": "MIT",
4066+ "dependencies": {
4067+ "cssesc": "^3.0.0",
4068+ "util-deprecate": "^1.0.2"
4069+ },
4070+ "engines": {
4071+ "node": ">=4"
4072+ }
4073+ },
38684074 "node_modules/postcss-value-parser": {
38694075 "version": "4.2.0",
38704076 "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@@ -4253,6 +4459,84 @@
42534459 "typescript": ">=5.0.0"
42544460 }
42554461 },
4462+ "node_modules/svelte-eslint-parser": {
4463+ "version": "1.4.1",
4464+ "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz",
4465+ "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==",
4466+ "dev": true,
4467+ "license": "MIT",
4468+ "dependencies": {
4469+ "eslint-scope": "^8.2.0",
4470+ "eslint-visitor-keys": "^4.0.0",
4471+ "espree": "^10.0.0",
4472+ "postcss": "^8.4.49",
4473+ "postcss-scss": "^4.0.9",
4474+ "postcss-selector-parser": "^7.0.0"
4475+ },
4476+ "engines": {
4477+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0",
4478+ "pnpm": "10.24.0"
4479+ },
4480+ "funding": {
4481+ "url": "https://github.com/sponsors/ota-meshi"
4482+ },
4483+ "peerDependencies": {
4484+ "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
4485+ },
4486+ "peerDependenciesMeta": {
4487+ "svelte": {
4488+ "optional": true
4489+ }
4490+ }
4491+ },
4492+ "node_modules/svelte-eslint-parser/node_modules/eslint-scope": {
4493+ "version": "8.4.0",
4494+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
4495+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
4496+ "dev": true,
4497+ "license": "BSD-2-Clause",
4498+ "dependencies": {
4499+ "esrecurse": "^4.3.0",
4500+ "estraverse": "^5.2.0"
4501+ },
4502+ "engines": {
4503+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
4504+ },
4505+ "funding": {
4506+ "url": "https://opencollective.com/eslint"
4507+ }
4508+ },
4509+ "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": {
4510+ "version": "4.2.1",
4511+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
4512+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
4513+ "dev": true,
4514+ "license": "Apache-2.0",
4515+ "engines": {
4516+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
4517+ },
4518+ "funding": {
4519+ "url": "https://opencollective.com/eslint"
4520+ }
4521+ },
4522+ "node_modules/svelte-eslint-parser/node_modules/espree": {
4523+ "version": "10.4.0",
4524+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
4525+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
4526+ "dev": true,
4527+ "license": "BSD-2-Clause",
4528+ "dependencies": {
4529+ "acorn": "^8.15.0",
4530+ "acorn-jsx": "^5.3.2",
4531+ "eslint-visitor-keys": "^4.2.1"
4532+ },
4533+ "engines": {
4534+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
4535+ },
4536+ "funding": {
4537+ "url": "https://opencollective.com/eslint"
4538+ }
4539+ },
42564540 "node_modules/svelte-toolbelt": {
42574541 "version": "0.10.6",
42584542 "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
@@ -4729,6 +5013,13 @@
47295013 "punycode": "^2.1.0"
47305014 }
47315015 },
5016+ "node_modules/util-deprecate": {
5017+ "version": "1.0.2",
5018+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
5019+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
5020+ "dev": true,
5021+ "license": "MIT"
5022+ },
47325023 "node_modules/vite": {
47335024 "version": "5.4.21",
47345025 "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -4843,6 +5134,16 @@
48435134 "dev": true,
48445135 "license": "ISC"
48455136 },
5137+ "node_modules/yaml": {
5138+ "version": "1.10.2",
5139+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
5140+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
5141+ "dev": true,
5142+ "license": "ISC",
5143+ "engines": {
5144+ "node": ">= 6"
5145+ }
5146+ },
48465147 "node_modules/yocto-queue": {
48475148 "version": "0.1.0",
48485149 "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
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 +128 -0
@@ -0,0 +1,128 @@
1+/**
2+ * WebKit bridge utilities for communicating with the Go backend.
3+ *
4+ * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived).
5+ * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it
6+ * fails silently, dropping the message entirely. This module provides safe
7+ * serialization that converts Proxies to plain objects before sending.
8+ */
9+
10+export interface BridgeMessage {
11+ type: string;
12+ webviewId: number;
13+ payload: Record<string, unknown>;
14+}
15+
16+interface WebKitBridge {
17+ postMessage: (msg: unknown) => void;
18+}
19+
20+/**
21+ * Get the WebKit message handler bridge, if available.
22+ */
23+export function getWebKitBridge(): WebKitBridge | null {
24+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25+ const bridge = (window as any).webkit?.messageHandlers?.dumber;
26+ return bridge && typeof bridge.postMessage === "function" ? bridge : null;
27+}
28+
29+/**
30+ * Get the current webview ID assigned by the Go backend.
31+ */
32+export function getWebViewId(): number {
33+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34+ return (window as any).__dumber_webview_id || 0;
35+}
36+
37+/**
38+ * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects.
39+ * Uses JSON serialization which strips Proxy wrappers.
40+ *
41+ * @throws Error if the object contains non-serializable values (functions, circular refs, etc.)
42+ */
43+export function toPlainObject<T>(obj: T): T {
44+ try {
45+ return JSON.parse(JSON.stringify(obj));
46+ } catch (err) {
47+ const message = err instanceof Error ? err.message : String(err);
48+ throw new Error(`Failed to serialize object for bridge: ${message}`);
49+ }
50+}
51+
52+/**
53+ * Post a message to the Go backend via WebKit bridge.
54+ *
55+ * Automatically:
56+ * - Converts Svelte 5 Proxy objects to plain objects
57+ * - Adds webviewId if not provided
58+ * - Logs errors if serialization fails
59+ *
60+ * @throws Error if bridge is not available or serialization fails
61+ */
62+export function postMessage(msg: BridgeMessage): void {
63+ const bridge = getWebKitBridge();
64+ if (!bridge) {
65+ throw new Error("WebKit bridge not available");
66+ }
67+
68+ // Ensure webviewId is set
69+ if (!msg.webviewId) {
70+ msg.webviewId = getWebViewId();
71+ }
72+
73+ // Convert to plain object to strip Svelte 5 Proxies
74+ // This is critical - WebKit silently drops messages with Proxy objects
75+ const plainMsg = toPlainObject(msg);
76+ bridge.postMessage(plainMsg);
77+}
78+
79+/**
80+ * Post a message and set up success/error callbacks with automatic cleanup.
81+ *
82+ * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
83+ * @param payload - Message payload (will be serialized to strip Proxies)
84+ * @param successCallback - Window function name to call on success
85+ * @param errorCallback - Window function name to call on error
86+ * @param onSuccess - Handler for success response
87+ * @param onError - Handler for error response
88+ */
89+export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
90+ type: string,
91+ payload: TPayload,
92+ successCallback: string,
93+ errorCallback: string,
94+ onSuccess: (response: TResponse) => void,
95+ onError: (error: string) => void,
96+): void {
97+ // Cleanup function to remove callbacks from window
98+ const cleanup = () => {
99+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100+ delete (window as any)[successCallback];
101+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102+ delete (window as any)[errorCallback];
103+ };
104+
105+ // Set up callbacks on window with automatic cleanup
106+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107+ (window as any)[successCallback] = (response: TResponse) => {
108+ cleanup();
109+ onSuccess(response);
110+ };
111+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112+ (window as any)[errorCallback] = (msg: string) => {
113+ cleanup();
114+ onError(typeof msg === "string" ? msg : "Unknown error");
115+ };
116+
117+ try {
118+ postMessage({
119+ type,
120+ webviewId: getWebViewId(),
121+ payload,
122+ });
123+ } catch (err) {
124+ cleanup();
125+ const message = err instanceof Error ? err.message : String(err);
126+ onError(message);
127+ }
128+}
M webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +12 -2
@@ -2,16 +2,26 @@
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 +2 -8
@@ -21,17 +21,11 @@
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- });
24+ // svelte-awesome-color-picker uses hex binding - use writable derived to sync with value
25+ let hex = $derived.by(() => value);
3126
3227 function handleInput(event: { hex: string | null }) {
3328 if (event.hex) {
34- hex = event.hex;
3529 value = event.hex;
3630 onValueChange?.(event.hex);
3731 }
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 +207 -0
@@ -0,0 +1,207 @@
1+<script lang="ts">
2+ import { onMount, untrack } from "svelte";
3+ import { Button } from "$lib/components/ui/button";
4+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
5+ import { Keyboard, X, Plus } from "@lucide/svelte";
6+ import { formatKey } from "$lib/utils/keys";
7+
8+ type KeybindingEntry = {
9+ action: string;
10+ description: string;
11+ keys: string[];
12+ default_keys: string[];
13+ is_custom: boolean;
14+ };
15+
16+ interface Props {
17+ binding: KeybindingEntry;
18+ onSave: (keys: string[]) => void;
19+ onCancel: () => void;
20+ }
21+
22+ let { binding, onSave, onCancel }: Props = $props();
23+
24+ // Initialize with a plain copy of the binding keys using $state.snapshot()
25+ // to strip Svelte 5 Proxy wrappers. untrack() ensures we only read the initial value.
26+ let newKeys = $state<string[]>(
27+ untrack(() => $state.snapshot(binding.keys) as string[])
28+ );
29+ let isCapturing = $state(false);
30+ let capturedKey = $state<string | null>(null);
31+ let isSaving = $state(false);
32+
33+ // Symbol keys that are already shifted (pressing shift produces these characters)
34+ // Don't add 'shift' modifier for these since they're self-describing
35+ const SHIFTED_SYMBOLS = new Set([
36+ "{", "}", "(", ")", "<", ">", "!", "@", "#", "$", "%", "^", "&", "*",
37+ "+", "_", ":", '"', "?", "|", "~",
38+ ]);
39+
40+ function handleKeyDown(e: KeyboardEvent) {
41+ if (!isCapturing) return;
42+ e.preventDefault();
43+ e.stopPropagation();
44+
45+ const parts: string[] = [];
46+ if (e.ctrlKey) parts.push("ctrl");
47+ if (e.altKey) parts.push("alt");
48+
49+ let key = e.key.toLowerCase();
50+ switch (key) {
51+ case "control":
52+ case "alt":
53+ case "shift":
54+ case "meta":
55+ return; // Modifier only, wait for actual key
56+ case "arrowleft":
57+ key = "arrowleft";
58+ break;
59+ case "arrowright":
60+ key = "arrowright";
61+ break;
62+ case "arrowup":
63+ key = "arrowup";
64+ break;
65+ case "arrowdown":
66+ key = "arrowdown";
67+ break;
68+ case "escape":
69+ // Escape closes the modal entirely
70+ onCancel();
71+ return;
72+ case " ":
73+ key = "space";
74+ break;
75+ case "enter":
76+ key = "enter";
77+ break;
78+ case "tab":
79+ key = "tab";
80+ break;
81+ case "backspace":
82+ key = "backspace";
83+ break;
84+ }
85+
86+ // Only add shift modifier for letter keys and non-symbol keys
87+ // Symbol keys like { } + etc. are already the shifted result
88+ if (e.shiftKey && !SHIFTED_SYMBOLS.has(e.key)) {
89+ parts.push("shift");
90+ }
91+
92+ parts.push(key);
93+ capturedKey = parts.join("+");
94+ isCapturing = false;
95+ }
96+
97+ function addCapturedKey() {
98+ if (capturedKey && !newKeys.includes(capturedKey)) {
99+ newKeys = [...newKeys, capturedKey];
100+ }
101+ capturedKey = null;
102+ }
103+
104+ function removeKey(index: number) {
105+ newKeys = newKeys.filter((_, i) => i !== index);
106+ }
107+
108+ function handleSave() {
109+ // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers
110+ const keysToSave = $state.snapshot(newKeys) as string[];
111+ // Auto-add any pending captured key before saving
112+ if (capturedKey && !keysToSave.includes(capturedKey)) {
113+ keysToSave.push(capturedKey);
114+ }
115+ isSaving = true;
116+ try {
117+ onSave(keysToSave);
118+ } finally {
119+ // Reset isSaving so modal remains dismissible if save fails
120+ isSaving = false;
121+ }
122+ }
123+
124+ onMount(() => {
125+ const handler = (e: KeyboardEvent) => handleKeyDown(e);
126+ window.addEventListener("keydown", handler, true);
127+ return () => window.removeEventListener("keydown", handler, true);
128+ });
129+</script>
130+
131+<AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}>
132+ <AlertDialog.Content class="max-w-md">
133+ <AlertDialog.Header>
134+ <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
135+ <AlertDialog.Description>
136+ Configure keybindings for <strong>{binding.action}</strong>
137+ </AlertDialog.Description>
138+ </AlertDialog.Header>
139+
140+ <div class="space-y-4 py-4">
141+ <!-- Current bindings -->
142+ <div class="space-y-2">
143+ <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
144+ Current Bindings
145+ </div>
146+ <div class="flex flex-wrap gap-2">
147+ {#each newKeys as key, i (key)}
148+ <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
149+ <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
150+ <button
151+ type="button"
152+ onclick={() => removeKey(i)}
153+ class="text-muted-foreground hover:text-foreground"
154+ >
155+ <X class="size-3" />
156+ </button>
157+ </div>
158+ {/each}
159+ {#if newKeys.length === 0}
160+ <span class="text-sm text-muted-foreground">(no bindings)</span>
161+ {/if}
162+ </div>
163+ </div>
164+
165+ <!-- Capture area -->
166+ <div class="space-y-2">
167+ <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
168+ Add New Binding
169+ </div>
170+ <div class="flex gap-2">
171+ <button
172+ type="button"
173+ onclick={() => { isCapturing = true; capturedKey = null; }}
174+ class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}"
175+ >
176+ <Keyboard class="size-5" />
177+ {#if isCapturing}
178+ Press a key combination...
179+ {:else if capturedKey}
180+ <kbd class="font-mono">{formatKey(capturedKey)}</kbd>
181+ {:else}
182+ Click to capture key
183+ {/if}
184+ </button>
185+ {#if capturedKey}
186+ <Button variant="outline" onclick={addCapturedKey}>
187+ <Plus class="mr-1 size-4" />
188+ Add
189+ </Button>
190+ {/if}
191+ </div>
192+ </div>
193+
194+ <!-- Default hint -->
195+ {#if binding.default_keys.length > 0}
196+ <div class="text-xs text-muted-foreground">
197+ Default: {binding.default_keys.map(formatKey).join(", ")}
198+ </div>
199+ {/if}
200+ </div>
201+
202+ <AlertDialog.Footer>
203+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
204+ <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action>
205+ </AlertDialog.Footer>
206+ </AlertDialog.Content>
207+</AlertDialog.Root>
A webui/src/pages/config/KeybindingGroup.svelte +95 -0
@@ -0,0 +1,95 @@
1+<script lang="ts">
2+ import { Button } from "$lib/components/ui/button";
3+ import * as Table from "$lib/components/ui/table";
4+ import { RotateCcw, Edit3 } from "@lucide/svelte";
5+ import { formatKey } from "$lib/utils/keys";
6+
7+ type KeybindingEntry = {
8+ action: string;
9+ description: string;
10+ keys: string[];
11+ default_keys: string[];
12+ is_custom: boolean;
13+ };
14+
15+ type KeybindingGroupData = {
16+ mode: string;
17+ display_name: string;
18+ bindings: KeybindingEntry[];
19+ activation?: string;
20+ };
21+
22+ interface Props {
23+ group: KeybindingGroupData;
24+ onEdit: (action: string, entry: KeybindingEntry) => void;
25+ onReset: (action: string) => void;
26+ }
27+
28+ let { group, onEdit, onReset }: Props = $props();
29+</script>
30+
31+<div class="space-y-3">
32+ <div class="flex items-center gap-2">
33+ <h3 class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
34+ {group.display_name}
35+ </h3>
36+ {#if group.activation}
37+ <span class="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground">
38+ {formatKey(group.activation)}
39+ </span>
40+ {/if}
41+ </div>
42+
43+ <Table.Root class="rounded-md border border-border">
44+ <Table.Header>
45+ <Table.Row>
46+ <Table.Head class="w-[180px]">Action</Table.Head>
47+ <Table.Head>Description</Table.Head>
48+ <Table.Head class="w-[200px]">Keybinding</Table.Head>
49+ <Table.Head class="w-[100px] text-right">Actions</Table.Head>
50+ </Table.Row>
51+ </Table.Header>
52+ <Table.Body>
53+ {#each group.bindings as binding (binding.action)}
54+ <Table.Row class={binding.is_custom ? "bg-accent/5" : ""}>
55+ <Table.Cell class="font-mono text-sm">{binding.action}</Table.Cell>
56+ <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell>
57+ <Table.Cell>
58+ <div class="flex flex-wrap gap-1">
59+ {#each binding.keys as key (key)}
60+ <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
61+ {formatKey(key)}
62+ </kbd>
63+ {/each}
64+ {#if binding.keys.length === 0}
65+ <span class="text-xs text-muted-foreground">(unbound)</span>
66+ {/if}
67+ </div>
68+ </Table.Cell>
69+ <Table.Cell class="text-right">
70+ <div class="flex justify-end gap-1">
71+ <Button
72+ variant="ghost"
73+ size="icon"
74+ onclick={() => onEdit(binding.action, binding)}
75+ aria-label="Edit binding"
76+ >
77+ <Edit3 class="size-4" />
78+ </Button>
79+ {#if binding.is_custom}
80+ <Button
81+ variant="ghost"
82+ size="icon"
83+ onclick={() => onReset(binding.action)}
84+ aria-label="Reset to default"
85+ >
86+ <RotateCcw class="size-4" />
87+ </Button>
88+ {/if}
89+ </div>
90+ </Table.Cell>
91+ </Table.Row>
92+ {/each}
93+ </Table.Body>
94+ </Table.Root>
95+</div>
A webui/src/pages/config/KeybindingsTab.svelte +340 -0
@@ -0,0 +1,340 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import { Button } from "$lib/components/ui/button";
4+ import { Spinner } from "$lib/components/ui/spinner";
5+ import * as Card from "$lib/components/ui/card";
6+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
7+ import { RefreshCw, RotateCcw, AlertTriangle, CheckCircle } from "@lucide/svelte";
8+ import { postMessage, getWebKitBridge, getWebViewId } from "$lib/bridge";
9+ import KeybindingGroup from "./KeybindingGroup.svelte";
10+ import KeyCaptureModal from "./KeyCaptureModal.svelte";
11+
12+ type KeybindingEntry = {
13+ action: string;
14+ description: string;
15+ keys: string[];
16+ default_keys: string[];
17+ is_custom: boolean;
18+ };
19+
20+ type KeybindingGroupData = {
21+ mode: string;
22+ display_name: string;
23+ bindings: KeybindingEntry[];
24+ activation?: string;
25+ };
26+
27+ type KeybindingsConfig = {
28+ groups: KeybindingGroupData[];
29+ };
30+
31+ type KeybindingConflict = {
32+ conflicting_action: string;
33+ conflicting_mode: string;
34+ key: string;
35+ };
36+
37+ let loading = $state(true);
38+ let error = $state<string | null>(null);
39+ let keybindings = $state<KeybindingsConfig | null>(null);
40+ let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
41+ let resetAllDialogOpen = $state(false);
42+ let successMessage = $state<string | null>(null);
43+ let conflicts = $state<KeybindingConflict[]>([]);
44+ let hasPendingReset = $state(false);
45+ let savingReset = $state(false);
46+
47+ // Type-safe callback setup helper
48+ function setupCallback<T>(name: string, handler: (data: T) => void): void {
49+ (window as unknown as Record<string, (data: T) => void>)[name] = handler;
50+ }
51+
52+ async function loadKeybindings() {
53+ loading = true;
54+ error = null;
55+
56+ if (!getWebKitBridge()) {
57+ error = "WebKit bridge not available";
58+ loading = false;
59+ return;
60+ }
61+
62+ setupCallback<KeybindingsConfig>("__dumber_keybindings_loaded", (data) => {
63+ keybindings = data;
64+ loading = false;
65+ });
66+ setupCallback<string>("__dumber_keybindings_error", (msg) => {
67+ error = typeof msg === "string" ? msg : "Failed to load keybindings";
68+ loading = false;
69+ });
70+
71+ try {
72+ postMessage({
73+ type: "get_keybindings",
74+ webviewId: getWebViewId(),
75+ payload: {},
76+ });
77+ } catch (err) {
78+ error = err instanceof Error ? err.message : "Failed to load keybindings";
79+ loading = false;
80+ }
81+ }
82+
83+ function startEdit(mode: string, action: string, entry: KeybindingEntry) {
84+ editingBinding = { mode, action, entry };
85+ }
86+
87+ function showSuccess(message: string) {
88+ successMessage = message;
89+ setTimeout(() => { successMessage = null; }, 3000);
90+ }
91+
92+ async function saveBinding(keys: string[]) {
93+ if (!editingBinding) return;
94+
95+ if (!getWebKitBridge()) {
96+ error = "WebKit bridge not available";
97+ return;
98+ }
99+
100+ // Timeout to detect if backend never responds (message silently dropped)
101+ const timeoutId = setTimeout(() => {
102+ error = "Save timed out - backend did not respond";
103+ }, 5000);
104+
105+ setupCallback<{ status: string; conflicts?: KeybindingConflict[] }>(
106+ "__dumber_keybinding_set",
107+ (response) => {
108+ clearTimeout(timeoutId);
109+ if (!response || response.status !== "success") {
110+ error = "Failed to save keybinding: invalid response";
111+ return;
112+ }
113+ conflicts = response.conflicts?.length ? response.conflicts : [];
114+ loadKeybindings();
115+ editingBinding = null;
116+ showSuccess("Keybinding saved successfully");
117+ }
118+ );
119+ setupCallback<string>("__dumber_keybinding_set_error", (msg) => {
120+ clearTimeout(timeoutId);
121+ error = typeof msg === "string" ? msg : "Failed to save keybinding";
122+ });
123+
124+ // Create plain payload - extract primitive values explicitly to avoid any proxy issues
125+ const mode = String(editingBinding.mode);
126+ const action = String(editingBinding.action);
127+ const payload = { mode, action, keys: [...keys] };
128+
129+ try {
130+ postMessage({
131+ type: "set_keybinding",
132+ webviewId: getWebViewId(),
133+ payload,
134+ });
135+ } catch (err) {
136+ clearTimeout(timeoutId);
137+ error = err instanceof Error ? err.message : "Failed to save keybinding";
138+ }
139+ }
140+
141+ async function resetBinding(mode: string, action: string) {
142+ if (!getWebKitBridge()) return;
143+
144+ setupCallback<void>("__dumber_keybinding_reset", () => {
145+ conflicts = [];
146+ loadKeybindings();
147+ showSuccess("Keybinding reset to default");
148+ });
149+ setupCallback<string>("__dumber_keybinding_reset_error", (msg) => {
150+ error = typeof msg === "string" ? msg : "Failed to reset keybinding";
151+ });
152+
153+ try {
154+ postMessage({
155+ type: "reset_keybinding",
156+ webviewId: getWebViewId(),
157+ payload: { mode, action },
158+ });
159+ } catch (err) {
160+ error = err instanceof Error ? err.message : "Failed to reset keybinding";
161+ }
162+ }
163+
164+ // Reset All: update local state to defaults WITHOUT saving
165+ function resetAllBindings() {
166+ if (!keybindings) return;
167+
168+ // Reset each binding's keys to default_keys in local state
169+ const resetGroups = keybindings.groups.map(group => ({
170+ ...group,
171+ bindings: group.bindings.map(binding => ({
172+ ...binding,
173+ keys: [...binding.default_keys],
174+ is_custom: false,
175+ })),
176+ }));
177+
178+ keybindings = { groups: resetGroups };
179+ hasPendingReset = true;
180+ conflicts = [];
181+ resetAllDialogOpen = false;
182+ showSuccess("Preview: all keybindings reset to defaults. Click Save to apply.");
183+ }
184+
185+ // Actually save the reset to backend
186+ async function saveResetAll() {
187+ if (!getWebKitBridge()) return;
188+
189+ savingReset = true;
190+
191+ setupCallback<void>("__dumber_keybindings_reset_all", () => {
192+ hasPendingReset = false;
193+ savingReset = false;
194+ loadKeybindings();
195+ showSuccess("All keybindings saved successfully");
196+ });
197+ setupCallback<string>("__dumber_keybindings_reset_all_error", (msg) => {
198+ error = typeof msg === "string" ? msg : "Failed to save keybindings";
199+ savingReset = false;
200+ });
201+
202+ try {
203+ postMessage({
204+ type: "reset_all_keybindings",
205+ webviewId: getWebViewId(),
206+ payload: {},
207+ });
208+ } catch (err) {
209+ error = err instanceof Error ? err.message : "Failed to save keybindings";
210+ savingReset = false;
211+ }
212+ }
213+
214+ // Cancel pending reset and reload from backend
215+ function cancelReset() {
216+ hasPendingReset = false;
217+ loadKeybindings();
218+ }
219+
220+ onMount(() => {
221+ loadKeybindings();
222+ });
223+</script>
224+
225+<Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
226+ <Card.Header>
227+ <div class="flex items-center justify-between">
228+ <div>
229+ <Card.Title>Keybindings</Card.Title>
230+ <Card.Description>
231+ Customize keyboard shortcuts for all modes.
232+ </Card.Description>
233+ </div>
234+ <div class="flex gap-2">
235+ {#if hasPendingReset}
236+ <Button variant="outline" size="sm" onclick={cancelReset} disabled={savingReset}>
237+ Cancel
238+ </Button>
239+ <Button size="sm" onclick={saveResetAll} disabled={savingReset}>
240+ {savingReset ? "Saving…" : "Save"}
241+ </Button>
242+ {:else}
243+ <Button variant="outline" size="sm" onclick={loadKeybindings}>
244+ <RefreshCw class="mr-1 size-4" />
245+ Refresh
246+ </Button>
247+ <AlertDialog.Root bind:open={resetAllDialogOpen}>
248+ <AlertDialog.Trigger>
249+ {#snippet child({ props })}
250+ <Button variant="outline" size="sm" {...props}>
251+ <RotateCcw class="mr-1 size-4" />
252+ Reset All
253+ </Button>
254+ {/snippet}
255+ </AlertDialog.Trigger>
256+ <AlertDialog.Content>
257+ <AlertDialog.Header>
258+ <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
259+ <AlertDialog.Description>
260+ This will preview all keybindings with their default values. You'll need to click Save to apply.
261+ </AlertDialog.Description>
262+ </AlertDialog.Header>
263+ <AlertDialog.Footer>
264+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
265+ <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>
266+ </AlertDialog.Footer>
267+ </AlertDialog.Content>
268+ </AlertDialog.Root>
269+ {/if}
270+ </div>
271+ </div>
272+ </Card.Header>
273+ <Card.Content class="space-y-6">
274+ {#if hasPendingReset}
275+ <div class="flex items-center gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
276+ <AlertTriangle class="size-4" />
277+ <span>Unsaved changes: all keybindings previewed with defaults. Click <strong>Save</strong> to apply or <strong>Cancel</strong> to discard.</span>
278+ </div>
279+ {/if}
280+ {#if loading}
281+ <div class="flex items-center justify-center py-8">
282+ <Spinner class="size-6" />
283+ </div>
284+ {:else if error}
285+ <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
286+ <AlertTriangle class="size-4" />
287+ {error}
288+ </div>
289+ {/if}
290+ {#if successMessage}
291+ <div class="flex items-center gap-2 rounded-md border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-600 dark:text-green-400">
292+ <CheckCircle class="size-4" />
293+ {successMessage}
294+ </div>
295+ {/if}
296+ {#if conflicts.length > 0}
297+ <div class="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm">
298+ <div class="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
299+ <AlertTriangle class="size-4" />
300+ <span class="font-medium">Keybinding conflicts detected</span>
301+ </div>
302+ <ul class="mt-2 list-disc pl-6 text-muted-foreground">
303+ {#each conflicts as conflict (conflict.key + conflict.conflicting_action)}
304+ <li>
305+ <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
306+ <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
307+ </li>
308+ {/each}
309+ </ul>
310+ <button
311+ type="button"
312+ class="mt-2 text-xs text-muted-foreground hover:text-foreground"
313+ onclick={() => conflicts = []}
314+ >
315+ Dismiss
316+ </button>
317+ </div>
318+ {/if}
319+ {#if !loading && !error && keybindings}
320+ {#each keybindings.groups as group (group.mode)}
321+ <KeybindingGroup
322+ {group}
323+ onEdit={(action, entry) => startEdit(group.mode, action, entry)}
324+ onReset={(action) => resetBinding(group.mode, action)}
325+ />
326+ {/each}
327+ {/if}
328+ {#if !loading && !keybindings && !error}
329+ <div class="text-center text-muted-foreground py-8">No keybindings available</div>
330+ {/if}
331+ </Card.Content>
332+</Card.Root>
333+
334+{#if editingBinding}
335+ <KeyCaptureModal
336+ binding={editingBinding.entry}
337+ onSave={saveBinding}
338+ onCancel={() => (editingBinding = null)}
339+ />
340+{/if}
M webui/src/pages/homepage/analytics/HourlyActivityChart.svelte +1 -0
@@ -19,6 +19,7 @@
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