detached 1960daf

refactor(keybindings): address PR review feedback

commit
1960dafff5ab5b50ac859218678186fa61b11277
author
brice · · 2026-01-24 08:35 UTC
parent
e36b143
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +113 -113
M docs/CONFIG.md +24 -9
@@ -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+["` | Consume into left sibling stack, or expel left if stacked (very alpha) |
316-| `workspace.shortcuts.consume_or_expel_right` | string | `"alt+]"` | Consume into right sibling stack, or expel right if stacked (very alpha) |
317-| `workspace.shortcuts.consume_or_expel_up` | string | `"alt+{"` | Consume into upper sibling stack, or expel up if stacked (very alpha) |
318-| `workspace.shortcuts.consume_or_expel_down` | string | `"alt+}"` | 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 |
321+
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+```
319332
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.
@@ -307,16 +307,31 @@ Notes:
307 307
308 ### Global Shortcuts308 ### Global Shortcuts
309 309
310-| Key | Type | Default | Description |310+Global shortcuts are configured under `workspace.shortcuts.actions` using the same `ActionBinding` structure as modal keybindings:
311-|-----|------|---------|-------------|311+
312-| `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |312+| Action | Default Keys | Description |
313-| `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |313+|--------|--------------|-------------|
314-| `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut |314+| `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) |
315-| `workspace.shortcuts.consume_or_expel_left` | string | `"alt+["` | Consume into left sibling stack, or expel left if stacked (very alpha) |315+| `next_tab` | `ctrl+tab` | Switch to next tab |
316-| `workspace.shortcuts.consume_or_expel_right` | string | `"alt+]"` | Consume into right sibling stack, or expel right if stacked (very alpha) |316+| `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |
317-| `workspace.shortcuts.consume_or_expel_up` | string | `"alt+{"` | Consume into upper sibling stack, or expel up if stacked (very alpha) |317+| `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |
318-| `workspace.shortcuts.consume_or_expel_down` | string | `"alt+}"` | Consume into lower sibling stack, or expel down if stacked (very alpha) |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 |
321+
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+```
319 332
333+> **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually.
334+>
320 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.335 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
321 >336 >
322 > **Note:** Consume-or-expel is experimental and may change behavior between releases.337 > **Note:** Consume-or-expel is experimental and may change behavior between releases.
M internal/application/usecase/keybindings.go +9 -2
@@ -7,6 +7,15 @@ import (
77 "github.com/bnema/dumber/internal/application/port"
88 )
99
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+
1019 // GetKeybindingsUseCase retrieves all keybindings.
1120 type GetKeybindingsUseCase struct {
1221 provider port.KeybindingsProvider
@@ -96,7 +105,6 @@ func validateSetKeybindingRequest(req port.SetKeybindingRequest) error {
96105 if req.Action == "" {
97106 return fmt.Errorf("action is required")
98107 }
99- validModes := map[string]bool{"global": true, "pane": true, "tab": true, "resize": true, "session": true}
100108 if !validModes[req.Mode] {
101109 return fmt.Errorf("invalid mode: %s", req.Mode)
102110 }
@@ -110,7 +118,6 @@ func validateResetKeybindingRequest(req port.ResetKeybindingRequest) error {
110118 if req.Action == "" {
111119 return fmt.Errorf("action is required")
112120 }
113- validModes := map[string]bool{"global": true, "pane": true, "tab": true, "resize": true, "session": true}
114121 if !validModes[req.Mode] {
115122 return fmt.Errorf("invalid mode: %s", req.Mode)
116123 }
@@ -7,6 +7,15 @@ import (
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 )8 )
9 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+
10 // GetKeybindingsUseCase retrieves all keybindings.19 // GetKeybindingsUseCase retrieves all keybindings.
11 type GetKeybindingsUseCase struct {20 type GetKeybindingsUseCase struct {
12 provider port.KeybindingsProvider21 provider port.KeybindingsProvider
@@ -96,7 +105,6 @@ func validateSetKeybindingRequest(req port.SetKeybindingRequest) error {
96 if req.Action == "" {105 if req.Action == "" {
97 return fmt.Errorf("action is required")106 return fmt.Errorf("action is required")
98 }107 }
99- validModes := map[string]bool{"global": true, "pane": true, "tab": true, "resize": true, "session": true}
100 if !validModes[req.Mode] {108 if !validModes[req.Mode] {
101 return fmt.Errorf("invalid mode: %s", req.Mode)109 return fmt.Errorf("invalid mode: %s", req.Mode)
102 }110 }
@@ -110,7 +118,6 @@ func validateResetKeybindingRequest(req port.ResetKeybindingRequest) error {
110 if req.Action == "" {118 if req.Action == "" {
111 return fmt.Errorf("action is required")119 return fmt.Errorf("action is required")
112 }120 }
113- validModes := map[string]bool{"global": true, "pane": true, "tab": true, "resize": true, "session": true}
114 if !validModes[req.Mode] {121 if !validModes[req.Mode] {
115 return fmt.Errorf("invalid mode: %s", req.Mode)122 return fmt.Errorf("invalid mode: %s", req.Mode)
116 }123 }
M internal/infrastructure/config/loader.go +0 -33
@@ -297,39 +297,6 @@ func (m *Manager) Save(cfg *Config) error {
297297 return nil
298298 }
299299
300-// SaveKeybindings saves keybinding-related configuration to disk with ordered sections.
301-func (m *Manager) SaveKeybindings(cfg *Config) error {
302- m.mu.Lock()
303- defer m.mu.Unlock()
304-
305- if cfg == nil {
306- return fmt.Errorf("config is nil")
307- }
308-
309- // Write full config with ordered sections
310- configFile := m.viper.ConfigFileUsed()
311- if configFile == "" {
312- var err error
313- configFile, err = GetConfigFile()
314- if err != nil {
315- return fmt.Errorf("failed to determine config file path: %w", err)
316- }
317- }
318-
319- if err := WriteConfigOrdered(cfg, configFile); err != nil {
320- return fmt.Errorf("failed to write config: %w", err)
321- }
322-
323- // Reload if not watching
324- if !m.watching {
325- if err := m.reload(); err != nil {
326- return err
327- }
328- }
329-
330- return nil
331-}
332-
333300 // GetConfigFile returns the path to the configuration file being used.
334301 func (m *Manager) GetConfigFile() string {
335302 return m.viper.ConfigFileUsed()
@@ -297,39 +297,6 @@ func (m *Manager) Save(cfg *Config) error {
297 return nil297 return nil
298 }298 }
299 299
300-// SaveKeybindings saves keybinding-related configuration to disk with ordered sections.
301-func (m *Manager) SaveKeybindings(cfg *Config) error {
302- m.mu.Lock()
303- defer m.mu.Unlock()
304-
305- if cfg == nil {
306- return fmt.Errorf("config is nil")
307- }
308-
309- // Write full config with ordered sections
310- configFile := m.viper.ConfigFileUsed()
311- if configFile == "" {
312- var err error
313- configFile, err = GetConfigFile()
314- if err != nil {
315- return fmt.Errorf("failed to determine config file path: %w", err)
316- }
317- }
318-
319- if err := WriteConfigOrdered(cfg, configFile); err != nil {
320- return fmt.Errorf("failed to write config: %w", err)
321- }
322-
323- // Reload if not watching
324- if !m.watching {
325- if err := m.reload(); err != nil {
326- return err
327- }
328- }
329-
330- return nil
331-}
332-
333 // GetConfigFile returns the path to the configuration file being used.300 // GetConfigFile returns the path to the configuration file being used.
334 func (m *Manager) GetConfigFile() string {301 func (m *Manager) GetConfigFile() string {
335 return m.viper.ConfigFileUsed()302 return m.viper.ConfigFileUsed()
M internal/infrastructure/webkit/handlers/keybindings.go +6 -6
@@ -75,7 +75,7 @@ func (*KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.Web
7575
7676 updateKeybinding(cfg, req)
7777
78- if err := mgr.SaveKeybindings(cfg); err != nil {
78+ if err := mgr.Save(cfg); err != nil {
7979 return nil, fmt.Errorf("failed to save: %w", err)
8080 }
8181
@@ -113,7 +113,7 @@ func (*KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ webkit.W
113113 cfg := mgr.Get()
114114 updateKeybinding(cfg, setReq)
115115
116- if err := mgr.SaveKeybindings(cfg); err != nil {
116+ if err := mgr.Save(cfg); err != nil {
117117 return nil, fmt.Errorf("failed to save: %w", err)
118118 }
119119
@@ -140,7 +140,7 @@ func (*KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ webk
140140 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts
141141 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions
142142
143- if err := mgr.SaveKeybindings(cfg); err != nil {
143+ if err := mgr.Save(cfg); err != nil {
144144 return nil, fmt.Errorf("failed to save: %w", err)
145145 }
146146
@@ -356,7 +356,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
356356 if err := router.RegisterHandlerWithCallbacks(
357357 "set_keybinding",
358358 "__dumber_keybinding_set",
359- "__dumber_keybinding_error",
359+ "__dumber_keybinding_set_error",
360360 "",
361361 webkit.MessageHandlerFunc(handler.HandleSetKeybinding),
362362 ); err != nil {
@@ -367,7 +367,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
367367 if err := router.RegisterHandlerWithCallbacks(
368368 "reset_keybinding",
369369 "__dumber_keybinding_reset",
370- "__dumber_keybinding_error",
370+ "__dumber_keybinding_reset_error",
371371 "",
372372 webkit.MessageHandlerFunc(handler.HandleResetKeybinding),
373373 ); err != nil {
@@ -378,7 +378,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
378378 if err := router.RegisterHandlerWithCallbacks(
379379 "reset_all_keybindings",
380380 "__dumber_keybindings_reset_all",
381- "__dumber_keybindings_error",
381+ "__dumber_keybindings_reset_all_error",
382382 "",
383383 webkit.MessageHandlerFunc(handler.HandleResetAllKeybindings),
384384 ); err != nil {
@@ -75,7 +75,7 @@ func (*KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.Web
75 75
76 updateKeybinding(cfg, req)76 updateKeybinding(cfg, req)
77 77
78- if err := mgr.SaveKeybindings(cfg); err != nil {78+ if err := mgr.Save(cfg); err != nil {
79 return nil, fmt.Errorf("failed to save: %w", err)79 return nil, fmt.Errorf("failed to save: %w", err)
80 }80 }
81 81
@@ -113,7 +113,7 @@ func (*KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ webkit.W
113 cfg := mgr.Get()113 cfg := mgr.Get()
114 updateKeybinding(cfg, setReq)114 updateKeybinding(cfg, setReq)
115 115
116- if err := mgr.SaveKeybindings(cfg); err != nil {116+ if err := mgr.Save(cfg); err != nil {
117 return nil, fmt.Errorf("failed to save: %w", err)117 return nil, fmt.Errorf("failed to save: %w", err)
118 }118 }
119 119
@@ -140,7 +140,7 @@ func (*KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ webk
140 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts140 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts
141 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions141 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions
142 142
143- if err := mgr.SaveKeybindings(cfg); err != nil {143+ if err := mgr.Save(cfg); err != nil {
144 return nil, fmt.Errorf("failed to save: %w", err)144 return nil, fmt.Errorf("failed to save: %w", err)
145 }145 }
146 146
@@ -356,7 +356,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
356 if err := router.RegisterHandlerWithCallbacks(356 if err := router.RegisterHandlerWithCallbacks(
357 "set_keybinding",357 "set_keybinding",
358 "__dumber_keybinding_set",358 "__dumber_keybinding_set",
359- "__dumber_keybinding_error",359+ "__dumber_keybinding_set_error",
360 "",360 "",
361 webkit.MessageHandlerFunc(handler.HandleSetKeybinding),361 webkit.MessageHandlerFunc(handler.HandleSetKeybinding),
362 ); err != nil {362 ); err != nil {
@@ -367,7 +367,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
367 if err := router.RegisterHandlerWithCallbacks(367 if err := router.RegisterHandlerWithCallbacks(
368 "reset_keybinding",368 "reset_keybinding",
369 "__dumber_keybinding_reset",369 "__dumber_keybinding_reset",
370- "__dumber_keybinding_error",370+ "__dumber_keybinding_reset_error",
371 "",371 "",
372 webkit.MessageHandlerFunc(handler.HandleResetKeybinding),372 webkit.MessageHandlerFunc(handler.HandleResetKeybinding),
373 ); err != nil {373 ); err != nil {
@@ -378,7 +378,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
378 if err := router.RegisterHandlerWithCallbacks(378 if err := router.RegisterHandlerWithCallbacks(
379 "reset_all_keybindings",379 "reset_all_keybindings",
380 "__dumber_keybindings_reset_all",380 "__dumber_keybindings_reset_all",
381- "__dumber_keybindings_error",381+ "__dumber_keybindings_reset_all_error",
382 "",382 "",
383 webkit.MessageHandlerFunc(handler.HandleResetAllKeybindings),383 webkit.MessageHandlerFunc(handler.HandleResetAllKeybindings),
384 ); err != nil {384 ); err != nil {
M webui/src/lib/bridge.ts +14 -15
@@ -60,8 +60,6 @@ export function toPlainObject<T>(obj: T): T {
6060 * @throws Error if bridge is not available or serialization fails
6161 */
6262 export function postMessage(msg: BridgeMessage): void {
63- console.log("[bridge] postMessage called with:", msg);
64-
6563 const bridge = getWebKitBridge();
6664 if (!bridge) {
6765 throw new Error("WebKit bridge not available");
@@ -74,22 +72,12 @@ export function postMessage(msg: BridgeMessage): void {
7472
7573 // Convert to plain object to strip Svelte 5 Proxies
7674 // This is critical - WebKit silently drops messages with Proxy objects
77- let plainMsg: BridgeMessage;
78- try {
79- plainMsg = toPlainObject(msg);
80- console.log("[bridge] Serialized message (Proxy stripped):", plainMsg);
81- } catch (err) {
82- console.error("[bridge] Failed to serialize message:", err, msg);
83- throw err;
84- }
85-
86- console.log("[bridge] Sending to WebKit bridge...");
75+ const plainMsg = toPlainObject(msg);
8776 bridge.postMessage(plainMsg);
88- console.log("[bridge] Message sent successfully");
8977 }
9078
9179 /**
92- * Post a message and set up success/error callbacks.
80+ * Post a message and set up success/error callbacks with automatic cleanup.
9381 *
9482 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
9583 * @param payload - Message payload (will be serialized to strip Proxies)
@@ -106,13 +94,23 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
10694 onSuccess: (response: TResponse) => void,
10795 onError: (error: string) => void,
10896 ): void {
109- // Set up callbacks on window
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
110106 // eslint-disable-next-line @typescript-eslint/no-explicit-any
111107 (window as any)[successCallback] = (response: TResponse) => {
108+ cleanup();
112109 onSuccess(response);
113110 };
114111 // eslint-disable-next-line @typescript-eslint/no-explicit-any
115112 (window as any)[errorCallback] = (msg: string) => {
113+ cleanup();
116114 onError(typeof msg === "string" ? msg : "Unknown error");
117115 };
118116
@@ -123,6 +121,7 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
123121 payload,
124122 });
125123 } catch (err) {
124+ cleanup();
126125 const message = err instanceof Error ? err.message : String(err);
127126 onError(message);
128127 }
@@ -60,8 +60,6 @@ export function toPlainObject<T>(obj: T): T {
60 * @throws Error if bridge is not available or serialization fails60 * @throws Error if bridge is not available or serialization fails
61 */61 */
62 export function postMessage(msg: BridgeMessage): void {62 export function postMessage(msg: BridgeMessage): void {
63- console.log("[bridge] postMessage called with:", msg);
64-
65 const bridge = getWebKitBridge();63 const bridge = getWebKitBridge();
66 if (!bridge) {64 if (!bridge) {
67 throw new Error("WebKit bridge not available");65 throw new Error("WebKit bridge not available");
@@ -74,22 +72,12 @@ export function postMessage(msg: BridgeMessage): void {
74 72
75 // Convert to plain object to strip Svelte 5 Proxies73 // Convert to plain object to strip Svelte 5 Proxies
76 // This is critical - WebKit silently drops messages with Proxy objects74 // This is critical - WebKit silently drops messages with Proxy objects
77- let plainMsg: BridgeMessage;75+ const plainMsg = toPlainObject(msg);
78- try {
79- plainMsg = toPlainObject(msg);
80- console.log("[bridge] Serialized message (Proxy stripped):", plainMsg);
81- } catch (err) {
82- console.error("[bridge] Failed to serialize message:", err, msg);
83- throw err;
84- }
85-
86- console.log("[bridge] Sending to WebKit bridge...");
87 bridge.postMessage(plainMsg);76 bridge.postMessage(plainMsg);
88- console.log("[bridge] Message sent successfully");
89 }77 }
90 78
91 /**79 /**
92- * Post a message and set up success/error callbacks.80+ * Post a message and set up success/error callbacks with automatic cleanup.
93 *81 *
94 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")82 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
95 * @param payload - Message payload (will be serialized to strip Proxies)83 * @param payload - Message payload (will be serialized to strip Proxies)
@@ -106,13 +94,23 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
106 onSuccess: (response: TResponse) => void,94 onSuccess: (response: TResponse) => void,
107 onError: (error: string) => void,95 onError: (error: string) => void,
108 ): void {96 ): void {
109- // Set up callbacks on window97+ // 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
110 // eslint-disable-next-line @typescript-eslint/no-explicit-any106 // eslint-disable-next-line @typescript-eslint/no-explicit-any
111 (window as any)[successCallback] = (response: TResponse) => {107 (window as any)[successCallback] = (response: TResponse) => {
108+ cleanup();
112 onSuccess(response);109 onSuccess(response);
113 };110 };
114 // eslint-disable-next-line @typescript-eslint/no-explicit-any111 // eslint-disable-next-line @typescript-eslint/no-explicit-any
115 (window as any)[errorCallback] = (msg: string) => {112 (window as any)[errorCallback] = (msg: string) => {
113+ cleanup();
116 onError(typeof msg === "string" ? msg : "Unknown error");114 onError(typeof msg === "string" ? msg : "Unknown error");
117 };115 };
118 116
@@ -123,6 +121,7 @@ export function postMessageWithCallbacks<TResponse, TPayload extends Record<stri
123 payload,121 payload,
124 });122 });
125 } catch (err) {123 } catch (err) {
124+ cleanup();
126 const message = err instanceof Error ? err.message : String(err);125 const message = err instanceof Error ? err.message : String(err);
127 onError(message);126 onError(message);
128 }127 }
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+}
@@ -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/config/KeyCaptureModal.svelte +1 -22
@@ -3,6 +3,7 @@
33 import { Button } from "$lib/components/ui/button";
44 import * as AlertDialog from "$lib/components/ui/alert-dialog";
55 import { Keyboard, X, Plus } from "@lucide/svelte";
6+ import { formatKey } from "$lib/utils/keys";
67
78 type KeybindingEntry = {
89 action: string;
@@ -29,10 +30,6 @@
2930 let capturedKey = $state<string | null>(null);
3031 let isSaving = $state(false);
3132
32- $effect(() => {
33- console.log("[KeyCaptureModal] State - newKeys:", newKeys, "capturedKey:", capturedKey);
34- });
35-
3633 function handleKeyDown(e: KeyboardEvent) {
3734 if (!isCapturing) return;
3835 e.preventDefault();
@@ -86,12 +83,8 @@
8683 }
8784
8885 function addCapturedKey() {
89- console.log("[KeyCaptureModal] addCapturedKey called, capturedKey:", capturedKey, "newKeys before:", newKeys);
9086 if (capturedKey && !newKeys.includes(capturedKey)) {
9187 newKeys = [...newKeys, capturedKey];
92- console.log("[KeyCaptureModal] Added key, newKeys after:", newKeys);
93- } else {
94- console.log("[KeyCaptureModal] Key not added (null or duplicate)");
9588 }
9689 capturedKey = null;
9790 }
@@ -100,18 +93,6 @@
10093 newKeys = newKeys.filter((_, i) => i !== index);
10194 }
10295
103- function formatKey(key: string): string {
104- return key
105- .replace(/arrowleft/gi, "Left")
106- .replace(/arrowright/gi, "Right")
107- .replace(/arrowup/gi, "Up")
108- .replace(/arrowdown/gi, "Down")
109- .replace(/\+/g, " + ")
110- .replace(/ctrl/gi, "Ctrl")
111- .replace(/alt/gi, "Alt")
112- .replace(/shift/gi, "Shift");
113- }
114-
11596 onMount(() => {
11697 const handler = (e: KeyboardEvent) => handleKeyDown(e);
11798 window.addEventListener("keydown", handler, true);
@@ -197,9 +178,7 @@
197178 let keysToSave = [...newKeys];
198179 if (capturedKey && !keysToSave.includes(capturedKey)) {
199180 keysToSave.push(capturedKey);
200- console.log("[KeyCaptureModal] Auto-added pending captured key:", capturedKey);
201181 }
202- console.log("[KeyCaptureModal] Save clicked, keysToSave:", keysToSave);
203182 isSaving = true;
204183 onSave(keysToSave);
205184 }}>Save</AlertDialog.Action>
@@ -3,6 +3,7 @@
3 import { Button } from "$lib/components/ui/button";3 import { Button } from "$lib/components/ui/button";
4 import * as AlertDialog from "$lib/components/ui/alert-dialog";4 import * as AlertDialog from "$lib/components/ui/alert-dialog";
5 import { Keyboard, X, Plus } from "@lucide/svelte";5 import { Keyboard, X, Plus } from "@lucide/svelte";
6+ import { formatKey } from "$lib/utils/keys";
6 7
7 type KeybindingEntry = {8 type KeybindingEntry = {
8 action: string;9 action: string;
@@ -29,10 +30,6 @@
29 let capturedKey = $state<string | null>(null);30 let capturedKey = $state<string | null>(null);
30 let isSaving = $state(false);31 let isSaving = $state(false);
31 32
32- $effect(() => {
33- console.log("[KeyCaptureModal] State - newKeys:", newKeys, "capturedKey:", capturedKey);
34- });
35-
36 function handleKeyDown(e: KeyboardEvent) {33 function handleKeyDown(e: KeyboardEvent) {
37 if (!isCapturing) return;34 if (!isCapturing) return;
38 e.preventDefault();35 e.preventDefault();
@@ -86,12 +83,8 @@
86 }83 }
87 84
88 function addCapturedKey() {85 function addCapturedKey() {
89- console.log("[KeyCaptureModal] addCapturedKey called, capturedKey:", capturedKey, "newKeys before:", newKeys);
90 if (capturedKey && !newKeys.includes(capturedKey)) {86 if (capturedKey && !newKeys.includes(capturedKey)) {
91 newKeys = [...newKeys, capturedKey];87 newKeys = [...newKeys, capturedKey];
92- console.log("[KeyCaptureModal] Added key, newKeys after:", newKeys);
93- } else {
94- console.log("[KeyCaptureModal] Key not added (null or duplicate)");
95 }88 }
96 capturedKey = null;89 capturedKey = null;
97 }90 }
@@ -100,18 +93,6 @@
100 newKeys = newKeys.filter((_, i) => i !== index);93 newKeys = newKeys.filter((_, i) => i !== index);
101 }94 }
102 95
103- function formatKey(key: string): string {
104- return key
105- .replace(/arrowleft/gi, "Left")
106- .replace(/arrowright/gi, "Right")
107- .replace(/arrowup/gi, "Up")
108- .replace(/arrowdown/gi, "Down")
109- .replace(/\+/g, " + ")
110- .replace(/ctrl/gi, "Ctrl")
111- .replace(/alt/gi, "Alt")
112- .replace(/shift/gi, "Shift");
113- }
114-
115 onMount(() => {96 onMount(() => {
116 const handler = (e: KeyboardEvent) => handleKeyDown(e);97 const handler = (e: KeyboardEvent) => handleKeyDown(e);
117 window.addEventListener("keydown", handler, true);98 window.addEventListener("keydown", handler, true);
@@ -197,9 +178,7 @@
197 let keysToSave = [...newKeys];178 let keysToSave = [...newKeys];
198 if (capturedKey && !keysToSave.includes(capturedKey)) {179 if (capturedKey && !keysToSave.includes(capturedKey)) {
199 keysToSave.push(capturedKey);180 keysToSave.push(capturedKey);
200- console.log("[KeyCaptureModal] Auto-added pending captured key:", capturedKey);
201 }181 }
202- console.log("[KeyCaptureModal] Save clicked, keysToSave:", keysToSave);
203 isSaving = true;182 isSaving = true;
204 onSave(keysToSave);183 onSave(keysToSave);
205 }}>Save</AlertDialog.Action>184 }}>Save</AlertDialog.Action>
M webui/src/pages/config/KeybindingGroup.svelte +1 -12
@@ -2,6 +2,7 @@
22 import { Button } from "$lib/components/ui/button";
33 import * as Table from "$lib/components/ui/table";
44 import { RotateCcw, Edit3 } from "@lucide/svelte";
5+ import { formatKey } from "$lib/utils/keys";
56
67 type KeybindingEntry = {
78 action: string;
@@ -25,18 +26,6 @@
2526 }
2627
2728 let { group, onEdit, onReset }: Props = $props();
28-
29- function formatKey(key: string): string {
30- return key
31- .replace(/arrowleft/gi, "Left")
32- .replace(/arrowright/gi, "Right")
33- .replace(/arrowup/gi, "Up")
34- .replace(/arrowdown/gi, "Down")
35- .replace(/\+/g, " + ")
36- .replace(/ctrl/gi, "Ctrl")
37- .replace(/alt/gi, "Alt")
38- .replace(/shift/gi, "Shift");
39- }
4029 </script>
4130
4231 <div class="space-y-3">
@@ -2,6 +2,7 @@
2 import { Button } from "$lib/components/ui/button";2 import { Button } from "$lib/components/ui/button";
3 import * as Table from "$lib/components/ui/table";3 import * as Table from "$lib/components/ui/table";
4 import { RotateCcw, Edit3 } from "@lucide/svelte";4 import { RotateCcw, Edit3 } from "@lucide/svelte";
5+ import { formatKey } from "$lib/utils/keys";
5 6
6 type KeybindingEntry = {7 type KeybindingEntry = {
7 action: string;8 action: string;
@@ -25,18 +26,6 @@
25 }26 }
26 27
27 let { group, onEdit, onReset }: Props = $props();28 let { group, onEdit, onReset }: Props = $props();
28-
29- function formatKey(key: string): string {
30- return key
31- .replace(/arrowleft/gi, "Left")
32- .replace(/arrowright/gi, "Right")
33- .replace(/arrowup/gi, "Up")
34- .replace(/arrowdown/gi, "Down")
35- .replace(/\+/g, " + ")
36- .replace(/ctrl/gi, "Ctrl")
37- .replace(/alt/gi, "Alt")
38- .replace(/shift/gi, "Shift");
39- }
40 </script>29 </script>
41 30
42 <div class="space-y-3">31 <div class="space-y-3">
M webui/src/pages/config/KeybindingsTab.svelte +7 -14
@@ -93,11 +93,9 @@
9393 }
9494
9595 (window as any).__dumber_keybinding_set = (response: { status: string; conflicts?: KeybindingConflict[] }) => {
96- console.log("[KeybindingsTab] Received set_keybinding response:", response);
9796 // Verify we got a valid success response from backend
9897 if (!response || response.status !== "success") {
9998 error = "Failed to save keybinding: invalid response";
100- console.error("[KeybindingsTab] Invalid response:", response);
10199 return;
102100 }
103101 if (response.conflicts && response.conflicts.length > 0) {
@@ -109,18 +107,11 @@
109107 editingBinding = null;
110108 showSuccess("Keybinding saved successfully");
111109 };
112- (window as any).__dumber_keybinding_error = (msg: string) => {
113- console.error("[KeybindingsTab] set_keybinding error:", msg);
110+ (window as any).__dumber_keybinding_set_error = (msg: string) => {
114111 error = typeof msg === "string" ? msg : "Failed to save keybinding";
115112 };
116113
117114 try {
118- console.log("[KeybindingsTab] Sending set_keybinding:", {
119- mode: editingBinding.mode,
120- action: editingBinding.action,
121- keys: keys,
122- });
123- // postMessage handles Svelte 5 Proxy serialization automatically
124115 postMessage({
125116 type: "set_keybinding",
126117 webviewId: getWebViewId(),
@@ -130,9 +121,7 @@
130121 keys: keys,
131122 },
132123 });
133- console.log("[KeybindingsTab] Message posted, waiting for callback...");
134124 } catch (err) {
135- console.error("[KeybindingsTab] postMessage failed:", err);
136125 error = err instanceof Error ? err.message : "Failed to save keybinding";
137126 }
138127 }
@@ -141,9 +130,11 @@
141130 if (!getWebKitBridge()) return;
142131
143132 (window as any).__dumber_keybinding_reset = () => {
133+ conflicts = []; // Clear conflicts after reset
144134 loadKeybindings();
135+ showSuccess("Keybinding reset to default");
145136 };
146- (window as any).__dumber_keybinding_error = (msg: string) => {
137+ (window as any).__dumber_keybinding_reset_error = (msg: string) => {
147138 error = typeof msg === "string" ? msg : "Failed to reset keybinding";
148139 };
149140
@@ -162,10 +153,12 @@
162153 if (!getWebKitBridge()) return;
163154
164155 (window as any).__dumber_keybindings_reset_all = () => {
156+ conflicts = []; // Clear conflicts after reset all
165157 loadKeybindings();
166158 resetAllDialogOpen = false;
159+ showSuccess("All keybindings reset to defaults");
167160 };
168- (window as any).__dumber_keybinding_error = (msg: string) => {
161+ (window as any).__dumber_keybindings_reset_all_error = (msg: string) => {
169162 error = typeof msg === "string" ? msg : "Failed to reset keybindings";
170163 };
171164
@@ -93,11 +93,9 @@
93 }93 }
94 94
95 (window as any).__dumber_keybinding_set = (response: { status: string; conflicts?: KeybindingConflict[] }) => {95 (window as any).__dumber_keybinding_set = (response: { status: string; conflicts?: KeybindingConflict[] }) => {
96- console.log("[KeybindingsTab] Received set_keybinding response:", response);
97 // Verify we got a valid success response from backend96 // Verify we got a valid success response from backend
98 if (!response || response.status !== "success") {97 if (!response || response.status !== "success") {
99 error = "Failed to save keybinding: invalid response";98 error = "Failed to save keybinding: invalid response";
100- console.error("[KeybindingsTab] Invalid response:", response);
101 return;99 return;
102 }100 }
103 if (response.conflicts && response.conflicts.length > 0) {101 if (response.conflicts && response.conflicts.length > 0) {
@@ -109,18 +107,11 @@
109 editingBinding = null;107 editingBinding = null;
110 showSuccess("Keybinding saved successfully");108 showSuccess("Keybinding saved successfully");
111 };109 };
112- (window as any).__dumber_keybinding_error = (msg: string) => {110+ (window as any).__dumber_keybinding_set_error = (msg: string) => {
113- console.error("[KeybindingsTab] set_keybinding error:", msg);
114 error = typeof msg === "string" ? msg : "Failed to save keybinding";111 error = typeof msg === "string" ? msg : "Failed to save keybinding";
115 };112 };
116 113
117 try {114 try {
118- console.log("[KeybindingsTab] Sending set_keybinding:", {
119- mode: editingBinding.mode,
120- action: editingBinding.action,
121- keys: keys,
122- });
123- // postMessage handles Svelte 5 Proxy serialization automatically
124 postMessage({115 postMessage({
125 type: "set_keybinding",116 type: "set_keybinding",
126 webviewId: getWebViewId(),117 webviewId: getWebViewId(),
@@ -130,9 +121,7 @@
130 keys: keys,121 keys: keys,
131 },122 },
132 });123 });
133- console.log("[KeybindingsTab] Message posted, waiting for callback...");
134 } catch (err) {124 } catch (err) {
135- console.error("[KeybindingsTab] postMessage failed:", err);
136 error = err instanceof Error ? err.message : "Failed to save keybinding";125 error = err instanceof Error ? err.message : "Failed to save keybinding";
137 }126 }
138 }127 }
@@ -141,9 +130,11 @@
141 if (!getWebKitBridge()) return;130 if (!getWebKitBridge()) return;
142 131
143 (window as any).__dumber_keybinding_reset = () => {132 (window as any).__dumber_keybinding_reset = () => {
133+ conflicts = []; // Clear conflicts after reset
144 loadKeybindings();134 loadKeybindings();
135+ showSuccess("Keybinding reset to default");
145 };136 };
146- (window as any).__dumber_keybinding_error = (msg: string) => {137+ (window as any).__dumber_keybinding_reset_error = (msg: string) => {
147 error = typeof msg === "string" ? msg : "Failed to reset keybinding";138 error = typeof msg === "string" ? msg : "Failed to reset keybinding";
148 };139 };
149 140
@@ -162,10 +153,12 @@
162 if (!getWebKitBridge()) return;153 if (!getWebKitBridge()) return;
163 154
164 (window as any).__dumber_keybindings_reset_all = () => {155 (window as any).__dumber_keybindings_reset_all = () => {
156+ conflicts = []; // Clear conflicts after reset all
165 loadKeybindings();157 loadKeybindings();
166 resetAllDialogOpen = false;158 resetAllDialogOpen = false;
159+ showSuccess("All keybindings reset to defaults");
167 };160 };
168- (window as any).__dumber_keybinding_error = (msg: string) => {161+ (window as any).__dumber_keybindings_reset_all_error = (msg: string) => {
169 error = typeof msg === "string" ? msg : "Failed to reset keybindings";162 error = typeof msg === "string" ? msg : "Failed to reset keybindings";
170 };163 };
171 164

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