detached 1ae6f9b

fix(keybindings): add bridge serialization for Svelte 5 Proxies and conflict detection

commit
1ae6f9bc5c7312045c545e3ab50761f44bdf2ea1
author
brice · · 2026-01-24 07:48 UTC
parent
a2e8a81
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +346 -64
M internal/infrastructure/webkit/handlers/keybindings.go +69 -4
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/logging"
1414 )
1515
16+const modeGlobal = "global"
1617
1718 // KeybindingsHandler handles keybinding-related messages.
1819 type KeybindingsHandler struct{}
@@ -49,9 +50,11 @@ func (*KeybindingsHandler) HandleGetKeybindings(ctx context.Context, _ webkit.We
4950 // HandleSetKeybinding updates a single keybinding.
5051 func (*KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
5152 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
53+ log.Debug().RawJSON("payload", payload).Msg("HandleSetKeybinding called")
5254
5355 var req port.SetKeybindingRequest
5456 if err := json.Unmarshal(payload, &req); err != nil {
57+ log.Error().Err(err).RawJSON("payload", payload).Msg("failed to unmarshal request")
5558 return nil, fmt.Errorf("invalid request: %w", err)
5659 }
5760
@@ -63,13 +66,23 @@ func (*KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.Web
6366 }
6467
6568 cfg := mgr.Get()
69+
70+ // Check for conflicts before saving
71+ conflicts := checkConflicts(cfg, req.Mode, req.Action, req.Keys)
72+ if len(conflicts) > 0 {
73+ log.Warn().Int("conflicts", len(conflicts)).Msg("keybinding conflicts detected")
74+ }
75+
6676 updateKeybinding(cfg, req)
6777
6878 if err := mgr.SaveKeybindings(cfg); err != nil {
6979 return nil, fmt.Errorf("failed to save: %w", err)
7080 }
7181
72- return map[string]any{"status": "success"}, nil
82+ return map[string]any{
83+ "status": "success",
84+ "conflicts": conflicts,
85+ }, nil
7386 }
7487
7588 // HandleResetKeybinding resets a keybinding to default.
@@ -137,7 +150,7 @@ func (*KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ webk
137150 // buildGlobalGroup builds the global shortcuts group.
138151 func buildGlobalGroup(cfg, defaults *config.Config) port.KeybindingGroup {
139152 return port.KeybindingGroup{
140- Mode: "global",
153+ Mode: modeGlobal,
141154 DisplayName: "Global Shortcuts",
142155 Bindings: buildModeBindings(cfg.Workspace.Shortcuts.Actions, defaults.Workspace.Shortcuts.Actions),
143156 }
@@ -213,7 +226,7 @@ func buildModeBindings(actions, defaultActions map[string]config.ActionBinding)
213226 // updateKeybinding updates a keybinding in the config.
214227 func updateKeybinding(cfg *config.Config, req port.SetKeybindingRequest) {
215228 switch req.Mode {
216- case "global":
229+ case modeGlobal:
217230 updateGlobalShortcut(cfg, req.Action, req.Keys)
218231 case "pane":
219232 if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok {
@@ -249,7 +262,7 @@ func updateGlobalShortcut(cfg *config.Config, action string, keys []string) {
249262 // getDefaultKeys returns the default keys for an action.
250263 func getDefaultKeys(defaults *config.Config, mode, action string) []string {
251264 switch mode {
252- case "global":
265+ case modeGlobal:
253266 return getDefaultGlobalKeys(defaults, action)
254267 case "pane":
255268 return defaults.Workspace.PaneMode.Actions[action].Keys
@@ -271,6 +284,58 @@ func getDefaultGlobalKeys(defaults *config.Config, action string) []string {
271284 return nil
272285 }
273286
287+// checkConflicts scans all modes for keybinding conflicts.
288+// Returns a list of conflicts where the same key is bound to different actions.
289+func checkConflicts(cfg *config.Config, targetMode, targetAction string, newKeys []string) []port.KeybindingConflict {
290+ var conflicts []port.KeybindingConflict
291+
292+ // Build a map of key -> (mode, action) for all bindings
293+ type binding struct {
294+ mode string
295+ action string
296+ }
297+ keyMap := make(map[string][]binding)
298+
299+ // Helper to add bindings from a mode
300+ addBindings := func(mode string, actions map[string]config.ActionBinding) {
301+ for action, ab := range actions {
302+ for _, key := range ab.Keys {
303+ keyMap[key] = append(keyMap[key], binding{mode: mode, action: action})
304+ }
305+ }
306+ }
307+
308+ // Collect all current bindings
309+ addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
310+ addBindings("pane", cfg.Workspace.PaneMode.Actions)
311+ addBindings("tab", cfg.Workspace.TabMode.Actions)
312+ addBindings("resize", cfg.Workspace.ResizeMode.Actions)
313+ addBindings("session", cfg.Session.SessionMode.Actions)
314+
315+ // Check each new key for conflicts
316+ for _, newKey := range newKeys {
317+ if existing, ok := keyMap[newKey]; ok {
318+ for _, b := range existing {
319+ // Skip if it's the same action we're updating
320+ if b.mode == targetMode && b.action == targetAction {
321+ continue
322+ }
323+ // Global shortcuts conflict with everything
324+ // Modal bindings only conflict within same mode or with global
325+ if targetMode == modeGlobal || b.mode == modeGlobal || b.mode == targetMode {
326+ conflicts = append(conflicts, port.KeybindingConflict{
327+ ConflictingAction: b.action,
328+ ConflictingMode: b.mode,
329+ Key: newKey,
330+ })
331+ }
332+ }
333+ }
334+ }
335+
336+ return conflicts
337+}
338+
274339 // RegisterKeybindingsHandlers registers keybindings handlers with the router.
275340 func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRouter) error {
276341 handler := NewKeybindingsHandler()
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14 )14 )
15 15
16+const modeGlobal = "global"
16 17
17 // KeybindingsHandler handles keybinding-related messages.18 // KeybindingsHandler handles keybinding-related messages.
18 type KeybindingsHandler struct{}19 type KeybindingsHandler struct{}
@@ -49,9 +50,11 @@ func (*KeybindingsHandler) HandleGetKeybindings(ctx context.Context, _ webkit.We
49 // HandleSetKeybinding updates a single keybinding.50 // HandleSetKeybinding updates a single keybinding.
50 func (*KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {51 func (*KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
51 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()52 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
53+ log.Debug().RawJSON("payload", payload).Msg("HandleSetKeybinding called")
52 54
53 var req port.SetKeybindingRequest55 var req port.SetKeybindingRequest
54 if err := json.Unmarshal(payload, &req); err != nil {56 if err := json.Unmarshal(payload, &req); err != nil {
57+ log.Error().Err(err).RawJSON("payload", payload).Msg("failed to unmarshal request")
55 return nil, fmt.Errorf("invalid request: %w", err)58 return nil, fmt.Errorf("invalid request: %w", err)
56 }59 }
57 60
@@ -63,13 +66,23 @@ func (*KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.Web
63 }66 }
64 67
65 cfg := mgr.Get()68 cfg := mgr.Get()
69+
70+ // Check for conflicts before saving
71+ conflicts := checkConflicts(cfg, req.Mode, req.Action, req.Keys)
72+ if len(conflicts) > 0 {
73+ log.Warn().Int("conflicts", len(conflicts)).Msg("keybinding conflicts detected")
74+ }
75+
66 updateKeybinding(cfg, req)76 updateKeybinding(cfg, req)
67 77
68 if err := mgr.SaveKeybindings(cfg); err != nil {78 if err := mgr.SaveKeybindings(cfg); err != nil {
69 return nil, fmt.Errorf("failed to save: %w", err)79 return nil, fmt.Errorf("failed to save: %w", err)
70 }80 }
71 81
72- return map[string]any{"status": "success"}, nil82+ return map[string]any{
83+ "status": "success",
84+ "conflicts": conflicts,
85+ }, nil
73 }86 }
74 87
75 // HandleResetKeybinding resets a keybinding to default.88 // HandleResetKeybinding resets a keybinding to default.
@@ -137,7 +150,7 @@ func (*KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ webk
137 // buildGlobalGroup builds the global shortcuts group.150 // buildGlobalGroup builds the global shortcuts group.
138 func buildGlobalGroup(cfg, defaults *config.Config) port.KeybindingGroup {151 func buildGlobalGroup(cfg, defaults *config.Config) port.KeybindingGroup {
139 return port.KeybindingGroup{152 return port.KeybindingGroup{
140- Mode: "global",153+ Mode: modeGlobal,
141 DisplayName: "Global Shortcuts",154 DisplayName: "Global Shortcuts",
142 Bindings: buildModeBindings(cfg.Workspace.Shortcuts.Actions, defaults.Workspace.Shortcuts.Actions),155 Bindings: buildModeBindings(cfg.Workspace.Shortcuts.Actions, defaults.Workspace.Shortcuts.Actions),
143 }156 }
@@ -213,7 +226,7 @@ func buildModeBindings(actions, defaultActions map[string]config.ActionBinding)
213 // updateKeybinding updates a keybinding in the config.226 // updateKeybinding updates a keybinding in the config.
214 func updateKeybinding(cfg *config.Config, req port.SetKeybindingRequest) {227 func updateKeybinding(cfg *config.Config, req port.SetKeybindingRequest) {
215 switch req.Mode {228 switch req.Mode {
216- case "global":229+ case modeGlobal:
217 updateGlobalShortcut(cfg, req.Action, req.Keys)230 updateGlobalShortcut(cfg, req.Action, req.Keys)
218 case "pane":231 case "pane":
219 if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok {232 if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok {
@@ -249,7 +262,7 @@ func updateGlobalShortcut(cfg *config.Config, action string, keys []string) {
249 // getDefaultKeys returns the default keys for an action.262 // getDefaultKeys returns the default keys for an action.
250 func getDefaultKeys(defaults *config.Config, mode, action string) []string {263 func getDefaultKeys(defaults *config.Config, mode, action string) []string {
251 switch mode {264 switch mode {
252- case "global":265+ case modeGlobal:
253 return getDefaultGlobalKeys(defaults, action)266 return getDefaultGlobalKeys(defaults, action)
254 case "pane":267 case "pane":
255 return defaults.Workspace.PaneMode.Actions[action].Keys268 return defaults.Workspace.PaneMode.Actions[action].Keys
@@ -271,6 +284,58 @@ func getDefaultGlobalKeys(defaults *config.Config, action string) []string {
271 return nil284 return nil
272 }285 }
273 286
287+// checkConflicts scans all modes for keybinding conflicts.
288+// Returns a list of conflicts where the same key is bound to different actions.
289+func checkConflicts(cfg *config.Config, targetMode, targetAction string, newKeys []string) []port.KeybindingConflict {
290+ var conflicts []port.KeybindingConflict
291+
292+ // Build a map of key -> (mode, action) for all bindings
293+ type binding struct {
294+ mode string
295+ action string
296+ }
297+ keyMap := make(map[string][]binding)
298+
299+ // Helper to add bindings from a mode
300+ addBindings := func(mode string, actions map[string]config.ActionBinding) {
301+ for action, ab := range actions {
302+ for _, key := range ab.Keys {
303+ keyMap[key] = append(keyMap[key], binding{mode: mode, action: action})
304+ }
305+ }
306+ }
307+
308+ // Collect all current bindings
309+ addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
310+ addBindings("pane", cfg.Workspace.PaneMode.Actions)
311+ addBindings("tab", cfg.Workspace.TabMode.Actions)
312+ addBindings("resize", cfg.Workspace.ResizeMode.Actions)
313+ addBindings("session", cfg.Session.SessionMode.Actions)
314+
315+ // Check each new key for conflicts
316+ for _, newKey := range newKeys {
317+ if existing, ok := keyMap[newKey]; ok {
318+ for _, b := range existing {
319+ // Skip if it's the same action we're updating
320+ if b.mode == targetMode && b.action == targetAction {
321+ continue
322+ }
323+ // Global shortcuts conflict with everything
324+ // Modal bindings only conflict within same mode or with global
325+ if targetMode == modeGlobal || b.mode == modeGlobal || b.mode == targetMode {
326+ conflicts = append(conflicts, port.KeybindingConflict{
327+ ConflictingAction: b.action,
328+ ConflictingMode: b.mode,
329+ Key: newKey,
330+ })
331+ }
332+ }
333+ }
334+ }
335+
336+ return conflicts
337+}
338+
274 // RegisterKeybindingsHandlers registers keybindings handlers with the router.339 // RegisterKeybindingsHandlers registers keybindings handlers with the router.
275 func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRouter) error {340 func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRouter) error {
276 handler := NewKeybindingsHandler()341 handler := NewKeybindingsHandler()
M internal/infrastructure/webkit/messaging.go +6 -1
@@ -187,7 +187,12 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
187187
188188 rawJSON := jscValue.ToJson(0)
189189 if rawJSON == "" {
190- log.Warn().Msg("script message JSON is empty")
190+ // This typically happens when the JS side sends non-serializable values
191+ // like Svelte 5 Proxy objects or functions. The frontend should use
192+ // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects.
193+ log.Error().
194+ Msg("script message JSON serialization failed (ToJson returned empty) - " +
195+ "this usually means JS sent non-serializable values like Proxy objects")
191196 return
192197 }
193198
@@ -187,7 +187,12 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
187 187
188 rawJSON := jscValue.ToJson(0)188 rawJSON := jscValue.ToJson(0)
189 if rawJSON == "" {189 if rawJSON == "" {
190- log.Warn().Msg("script message JSON is empty")190+ // This typically happens when the JS side sends non-serializable values
191+ // like Svelte 5 Proxy objects or functions. The frontend should use
192+ // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects.
193+ log.Error().
194+ Msg("script message JSON serialization failed (ToJson returned empty) - " +
195+ "this usually means JS sent non-serializable values like Proxy objects")
191 return196 return
192 }197 }
193 198
A webui/src/lib/bridge.ts +129 -0
@@ -0,0 +1,129 @@
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+ console.log("[bridge] postMessage called with:", msg);
64+
65+ const bridge = getWebKitBridge();
66+ if (!bridge) {
67+ throw new Error("WebKit bridge not available");
68+ }
69+
70+ // Ensure webviewId is set
71+ if (!msg.webviewId) {
72+ msg.webviewId = getWebViewId();
73+ }
74+
75+ // Convert to plain object to strip Svelte 5 Proxies
76+ // 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...");
87+ bridge.postMessage(plainMsg);
88+ console.log("[bridge] Message sent successfully");
89+}
90+
91+/**
92+ * Post a message and set up success/error callbacks.
93+ *
94+ * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
95+ * @param payload - Message payload (will be serialized to strip Proxies)
96+ * @param successCallback - Window function name to call on success
97+ * @param errorCallback - Window function name to call on error
98+ * @param onSuccess - Handler for success response
99+ * @param onError - Handler for error response
100+ */
101+export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
102+ type: string,
103+ payload: TPayload,
104+ successCallback: string,
105+ errorCallback: string,
106+ onSuccess: (response: TResponse) => void,
107+ onError: (error: string) => void,
108+): void {
109+ // Set up callbacks on window
110+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111+ (window as any)[successCallback] = (response: TResponse) => {
112+ onSuccess(response);
113+ };
114+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115+ (window as any)[errorCallback] = (msg: string) => {
116+ onError(typeof msg === "string" ? msg : "Unknown error");
117+ };
118+
119+ try {
120+ postMessage({
121+ type,
122+ webviewId: getWebViewId(),
123+ payload,
124+ });
125+ } catch (err) {
126+ const message = err instanceof Error ? err.message : String(err);
127+ onError(message);
128+ }
129+}
@@ -0,0 +1,129 @@
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+ console.log("[bridge] postMessage called with:", msg);
64+
65+ const bridge = getWebKitBridge();
66+ if (!bridge) {
67+ throw new Error("WebKit bridge not available");
68+ }
69+
70+ // Ensure webviewId is set
71+ if (!msg.webviewId) {
72+ msg.webviewId = getWebViewId();
73+ }
74+
75+ // Convert to plain object to strip Svelte 5 Proxies
76+ // 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...");
87+ bridge.postMessage(plainMsg);
88+ console.log("[bridge] Message sent successfully");
89+}
90+
91+/**
92+ * Post a message and set up success/error callbacks.
93+ *
94+ * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
95+ * @param payload - Message payload (will be serialized to strip Proxies)
96+ * @param successCallback - Window function name to call on success
97+ * @param errorCallback - Window function name to call on error
98+ * @param onSuccess - Handler for success response
99+ * @param onError - Handler for error response
100+ */
101+export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
102+ type: string,
103+ payload: TPayload,
104+ successCallback: string,
105+ errorCallback: string,
106+ onSuccess: (response: TResponse) => void,
107+ onError: (error: string) => void,
108+): void {
109+ // Set up callbacks on window
110+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111+ (window as any)[successCallback] = (response: TResponse) => {
112+ onSuccess(response);
113+ };
114+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115+ (window as any)[errorCallback] = (msg: string) => {
116+ onError(typeof msg === "string" ? msg : "Unknown error");
117+ };
118+
119+ try {
120+ postMessage({
121+ type,
122+ webviewId: getWebViewId(),
123+ payload,
124+ });
125+ } catch (err) {
126+ const message = err instanceof Error ? err.message : String(err);
127+ onError(message);
128+ }
129+}
M webui/src/pages/config/KeyCaptureModal.svelte +26 -13
@@ -1,5 +1,5 @@
11 <script lang="ts">
2- import { onMount } from "svelte";
2+ import { onMount, untrack } from "svelte";
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";
@@ -20,18 +20,17 @@
2020
2121 let { binding, onSave, onCancel }: Props = $props();
2222
23- // Use $derived for initial keys to maintain reactivity if binding changes
24- let newKeys = $state<string[]>([]);
23+ // Initialize with a plain copy of the binding keys (not a Proxy reference)
24+ // untrack() tells Svelte we intentionally want the initial value only
25+ let newKeys = $state<string[]>(
26+ untrack(() => JSON.parse(JSON.stringify(binding.keys)) as string[])
27+ );
2528 let isCapturing = $state(false);
2629 let capturedKey = $state<string | null>(null);
27- let initialized = $state(false);
30+ let isSaving = $state(false);
2831
29- // Initialize keys from binding on mount
3032 $effect(() => {
31- if (!initialized) {
32- newKeys = [...binding.keys];
33- initialized = true;
34- }
33+ console.log("[KeyCaptureModal] State - newKeys:", newKeys, "capturedKey:", capturedKey);
3534 });
3635
3736 function handleKeyDown(e: KeyboardEvent) {
@@ -64,8 +63,8 @@
6463 key = "arrowdown";
6564 break;
6665 case "escape":
67- isCapturing = false;
68- capturedKey = null;
66+ // Escape closes the modal entirely
67+ onCancel();
6968 return;
7069 case " ":
7170 key = "space";
@@ -87,8 +86,12 @@
8786 }
8887
8988 function addCapturedKey() {
89+ console.log("[KeyCaptureModal] addCapturedKey called, capturedKey:", capturedKey, "newKeys before:", newKeys);
9090 if (capturedKey && !newKeys.includes(capturedKey)) {
9191 newKeys = [...newKeys, capturedKey];
92+ console.log("[KeyCaptureModal] Added key, newKeys after:", newKeys);
93+ } else {
94+ console.log("[KeyCaptureModal] Key not added (null or duplicate)");
9295 }
9396 capturedKey = null;
9497 }
@@ -116,7 +119,7 @@
116119 });
117120 </script>
118121
119-<AlertDialog.Root open={true} onOpenChange={(open) => !open && onCancel()}>
122+<AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}>
120123 <AlertDialog.Content class="max-w-md">
121124 <AlertDialog.Header>
122125 <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
@@ -189,7 +192,17 @@
189192
190193 <AlertDialog.Footer>
191194 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
192- <AlertDialog.Action onclick={() => { console.log("[DEBUG] Save clicked, newKeys:", newKeys); onSave(newKeys); }}>Save</AlertDialog.Action>
195+ <AlertDialog.Action onclick={() => {
196+ // Auto-add any pending captured key before saving
197+ let keysToSave = [...newKeys];
198+ if (capturedKey && !keysToSave.includes(capturedKey)) {
199+ keysToSave.push(capturedKey);
200+ console.log("[KeyCaptureModal] Auto-added pending captured key:", capturedKey);
201+ }
202+ console.log("[KeyCaptureModal] Save clicked, keysToSave:", keysToSave);
203+ isSaving = true;
204+ onSave(keysToSave);
205+ }}>Save</AlertDialog.Action>
193206 </AlertDialog.Footer>
194207 </AlertDialog.Content>
195208 </AlertDialog.Root>
@@ -1,5 +1,5 @@
1 <script lang="ts">1 <script lang="ts">
2- import { onMount } from "svelte";2+ import { onMount, untrack } from "svelte";
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";
@@ -20,18 +20,17 @@
20 20
21 let { binding, onSave, onCancel }: Props = $props();21 let { binding, onSave, onCancel }: Props = $props();
22 22
23- // Use $derived for initial keys to maintain reactivity if binding changes23+ // Initialize with a plain copy of the binding keys (not a Proxy reference)
24- let newKeys = $state<string[]>([]);24+ // untrack() tells Svelte we intentionally want the initial value only
25+ let newKeys = $state<string[]>(
26+ untrack(() => JSON.parse(JSON.stringify(binding.keys)) as string[])
27+ );
25 let isCapturing = $state(false);28 let isCapturing = $state(false);
26 let capturedKey = $state<string | null>(null);29 let capturedKey = $state<string | null>(null);
27- let initialized = $state(false);30+ let isSaving = $state(false);
28 31
29- // Initialize keys from binding on mount
30 $effect(() => {32 $effect(() => {
31- if (!initialized) {33+ console.log("[KeyCaptureModal] State - newKeys:", newKeys, "capturedKey:", capturedKey);
32- newKeys = [...binding.keys];
33- initialized = true;
34- }
35 });34 });
36 35
37 function handleKeyDown(e: KeyboardEvent) {36 function handleKeyDown(e: KeyboardEvent) {
@@ -64,8 +63,8 @@
64 key = "arrowdown";63 key = "arrowdown";
65 break;64 break;
66 case "escape":65 case "escape":
67- isCapturing = false;66+ // Escape closes the modal entirely
68- capturedKey = null;67+ onCancel();
69 return;68 return;
70 case " ":69 case " ":
71 key = "space";70 key = "space";
@@ -87,8 +86,12 @@
87 }86 }
88 87
89 function addCapturedKey() {88 function addCapturedKey() {
89+ console.log("[KeyCaptureModal] addCapturedKey called, capturedKey:", capturedKey, "newKeys before:", newKeys);
90 if (capturedKey && !newKeys.includes(capturedKey)) {90 if (capturedKey && !newKeys.includes(capturedKey)) {
91 newKeys = [...newKeys, capturedKey];91 newKeys = [...newKeys, capturedKey];
92+ console.log("[KeyCaptureModal] Added key, newKeys after:", newKeys);
93+ } else {
94+ console.log("[KeyCaptureModal] Key not added (null or duplicate)");
92 }95 }
93 capturedKey = null;96 capturedKey = null;
94 }97 }
@@ -116,7 +119,7 @@
116 });119 });
117 </script>120 </script>
118 121
119-<AlertDialog.Root open={true} onOpenChange={(open) => !open && onCancel()}>122+<AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}>
120 <AlertDialog.Content class="max-w-md">123 <AlertDialog.Content class="max-w-md">
121 <AlertDialog.Header>124 <AlertDialog.Header>
122 <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>125 <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
@@ -189,7 +192,17 @@
189 192
190 <AlertDialog.Footer>193 <AlertDialog.Footer>
191 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>194 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
192- <AlertDialog.Action onclick={() => { console.log("[DEBUG] Save clicked, newKeys:", newKeys); onSave(newKeys); }}>Save</AlertDialog.Action>195+ <AlertDialog.Action onclick={() => {
196+ // Auto-add any pending captured key before saving
197+ let keysToSave = [...newKeys];
198+ if (capturedKey && !keysToSave.includes(capturedKey)) {
199+ keysToSave.push(capturedKey);
200+ console.log("[KeyCaptureModal] Auto-added pending captured key:", capturedKey);
201+ }
202+ console.log("[KeyCaptureModal] Save clicked, keysToSave:", keysToSave);
203+ isSaving = true;
204+ onSave(keysToSave);
205+ }}>Save</AlertDialog.Action>
193 </AlertDialog.Footer>206 </AlertDialog.Footer>
194 </AlertDialog.Content>207 </AlertDialog.Content>
195 </AlertDialog.Root>208 </AlertDialog.Root>
M webui/src/pages/config/KeybindingsTab.svelte +116 -46
@@ -4,7 +4,8 @@
44 import { Spinner } from "$lib/components/ui/spinner";
55 import * as Card from "$lib/components/ui/card";
66 import * as AlertDialog from "$lib/components/ui/alert-dialog";
7- import { RefreshCw, RotateCcw, AlertTriangle } from "@lucide/svelte";
7+ import { RefreshCw, RotateCcw, AlertTriangle, CheckCircle } from "@lucide/svelte";
8+ import { postMessage, getWebKitBridge, getWebViewId } from "$lib/bridge";
89 import KeybindingGroup from "./KeybindingGroup.svelte";
910 import KeyCaptureModal from "./KeyCaptureModal.svelte";
1011
@@ -27,27 +28,25 @@
2728 groups: KeybindingGroupData[];
2829 };
2930
31+ type KeybindingConflict = {
32+ conflicting_action: string;
33+ conflicting_mode: string;
34+ key: string;
35+ };
36+
3037 let loading = $state(true);
3138 let error = $state<string | null>(null);
3239 let keybindings = $state<KeybindingsConfig | null>(null);
3340 let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
3441 let resetAllDialogOpen = $state(false);
35-
36- function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
37- const bridge = (window as any).webkit?.messageHandlers?.dumber;
38- return bridge && typeof bridge.postMessage === "function" ? bridge : null;
39- }
40-
41- function getWebViewId(): number {
42- return (window as any).__dumber_webview_id || 0;
43- }
42+ let successMessage = $state<string | null>(null);
43+ let conflicts = $state<KeybindingConflict[]>([]);
4444
4545 async function loadKeybindings() {
4646 loading = true;
4747 error = null;
4848
49- const bridge = getWebKitBridge();
50- if (!bridge) {
49+ if (!getWebKitBridge()) {
5150 error = "WebKit bridge not available";
5251 loading = false;
5352 return;
@@ -62,53 +61,84 @@
6261 loading = false;
6362 };
6463
65- bridge.postMessage({
66- type: "get_keybindings",
67- webviewId: getWebViewId(),
68- payload: {},
69- });
64+ try {
65+ postMessage({
66+ type: "get_keybindings",
67+ webviewId: getWebViewId(),
68+ payload: {},
69+ });
70+ } catch (err) {
71+ error = err instanceof Error ? err.message : "Failed to load keybindings";
72+ loading = false;
73+ }
7074 }
7175
7276 function startEdit(mode: string, action: string, entry: KeybindingEntry) {
7377 editingBinding = { mode, action, entry };
7478 }
7579
80+ function showSuccess(message: string) {
81+ successMessage = message;
82+ setTimeout(() => { successMessage = null; }, 3000);
83+ }
84+
7685 async function saveBinding(keys: string[]) {
77- console.log("[DEBUG] saveBinding called with keys:", keys, "editingBinding:", editingBinding);
7886 if (!editingBinding) {
79- console.log("[DEBUG] saveBinding: no editingBinding, returning");
8087 return;
8188 }
8289
83- const bridge = getWebKitBridge();
84- console.log("[DEBUG] saveBinding: bridge =", bridge);
85- if (!bridge) {
86- console.log("[DEBUG] saveBinding: no bridge, returning");
90+ if (!getWebKitBridge()) {
91+ error = "WebKit bridge not available";
8792 return;
8893 }
8994
90- (window as any).__dumber_keybinding_set = () => {
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 backend
98+ if (!response || response.status !== "success") {
99+ error = "Failed to save keybinding: invalid response";
100+ console.error("[KeybindingsTab] Invalid response:", response);
101+ return;
102+ }
103+ if (response.conflicts && response.conflicts.length > 0) {
104+ conflicts = response.conflicts;
105+ } else {
106+ conflicts = [];
107+ }
91108 loadKeybindings();
92109 editingBinding = null;
110+ showSuccess("Keybinding saved successfully");
93111 };
94112 (window as any).__dumber_keybinding_error = (msg: string) => {
113+ console.error("[KeybindingsTab] set_keybinding error:", msg);
95114 error = typeof msg === "string" ? msg : "Failed to save keybinding";
96115 };
97116
98- bridge.postMessage({
99- type: "set_keybinding",
100- webviewId: getWebViewId(),
101- payload: {
117+ try {
118+ console.log("[KeybindingsTab] Sending set_keybinding:", {
102119 mode: editingBinding.mode,
103120 action: editingBinding.action,
104- keys,
105- },
106- });
121+ keys: keys,
122+ });
123+ // postMessage handles Svelte 5 Proxy serialization automatically
124+ postMessage({
125+ type: "set_keybinding",
126+ webviewId: getWebViewId(),
127+ payload: {
128+ mode: editingBinding.mode,
129+ action: editingBinding.action,
130+ keys: keys,
131+ },
132+ });
133+ console.log("[KeybindingsTab] Message posted, waiting for callback...");
134+ } catch (err) {
135+ console.error("[KeybindingsTab] postMessage failed:", err);
136+ error = err instanceof Error ? err.message : "Failed to save keybinding";
137+ }
107138 }
108139
109140 async function resetBinding(mode: string, action: string) {
110- const bridge = getWebKitBridge();
111- if (!bridge) return;
141+ if (!getWebKitBridge()) return;
112142
113143 (window as any).__dumber_keybinding_reset = () => {
114144 loadKeybindings();
@@ -117,16 +147,19 @@
117147 error = typeof msg === "string" ? msg : "Failed to reset keybinding";
118148 };
119149
120- bridge.postMessage({
121- type: "reset_keybinding",
122- webviewId: getWebViewId(),
123- payload: { mode, action },
124- });
150+ try {
151+ postMessage({
152+ type: "reset_keybinding",
153+ webviewId: getWebViewId(),
154+ payload: { mode, action },
155+ });
156+ } catch (err) {
157+ error = err instanceof Error ? err.message : "Failed to reset keybinding";
158+ }
125159 }
126160
127161 async function resetAllBindings() {
128- const bridge = getWebKitBridge();
129- if (!bridge) return;
162+ if (!getWebKitBridge()) return;
130163
131164 (window as any).__dumber_keybindings_reset_all = () => {
132165 loadKeybindings();
@@ -136,11 +169,15 @@
136169 error = typeof msg === "string" ? msg : "Failed to reset keybindings";
137170 };
138171
139- bridge.postMessage({
140- type: "reset_all_keybindings",
141- webviewId: getWebViewId(),
142- payload: {},
143- });
172+ try {
173+ postMessage({
174+ type: "reset_all_keybindings",
175+ webviewId: getWebViewId(),
176+ payload: {},
177+ });
178+ } catch (err) {
179+ error = err instanceof Error ? err.message : "Failed to reset keybindings";
180+ }
144181 }
145182
146183 onMount(() => {
@@ -197,7 +234,37 @@
197234 <AlertTriangle class="size-4" />
198235 {error}
199236 </div>
200- {:else if keybindings}
237+ {/if}
238+ {#if successMessage}
239+ <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">
240+ <CheckCircle class="size-4" />
241+ {successMessage}
242+ </div>
243+ {/if}
244+ {#if conflicts.length > 0}
245+ <div class="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm">
246+ <div class="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
247+ <AlertTriangle class="size-4" />
248+ <span class="font-medium">Keybinding conflicts detected</span>
249+ </div>
250+ <ul class="mt-2 list-disc pl-6 text-muted-foreground">
251+ {#each conflicts as conflict}
252+ <li>
253+ <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
254+ <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
255+ </li>
256+ {/each}
257+ </ul>
258+ <button
259+ type="button"
260+ class="mt-2 text-xs text-muted-foreground hover:text-foreground"
261+ onclick={() => conflicts = []}
262+ >
263+ Dismiss
264+ </button>
265+ </div>
266+ {/if}
267+ {#if !loading && !error && keybindings}
201268 {#each keybindings.groups as group (group.mode)}
202269 <KeybindingGroup
203270 {group}
@@ -206,6 +273,9 @@
206273 />
207274 {/each}
208275 {/if}
276+ {#if !loading && !keybindings && !error}
277+ <div class="text-center text-muted-foreground py-8">No keybindings available</div>
278+ {/if}
209279 </Card.Content>
210280 </Card.Root>
211281
@@ -4,7 +4,8 @@
4 import { Spinner } from "$lib/components/ui/spinner";4 import { Spinner } from "$lib/components/ui/spinner";
5 import * as Card from "$lib/components/ui/card";5 import * as Card from "$lib/components/ui/card";
6 import * as AlertDialog from "$lib/components/ui/alert-dialog";6 import * as AlertDialog from "$lib/components/ui/alert-dialog";
7- import { RefreshCw, RotateCcw, AlertTriangle } from "@lucide/svelte";7+ import { RefreshCw, RotateCcw, AlertTriangle, CheckCircle } from "@lucide/svelte";
8+ import { postMessage, getWebKitBridge, getWebViewId } from "$lib/bridge";
8 import KeybindingGroup from "./KeybindingGroup.svelte";9 import KeybindingGroup from "./KeybindingGroup.svelte";
9 import KeyCaptureModal from "./KeyCaptureModal.svelte";10 import KeyCaptureModal from "./KeyCaptureModal.svelte";
10 11
@@ -27,27 +28,25 @@
27 groups: KeybindingGroupData[];28 groups: KeybindingGroupData[];
28 };29 };
29 30
31+ type KeybindingConflict = {
32+ conflicting_action: string;
33+ conflicting_mode: string;
34+ key: string;
35+ };
36+
30 let loading = $state(true);37 let loading = $state(true);
31 let error = $state<string | null>(null);38 let error = $state<string | null>(null);
32 let keybindings = $state<KeybindingsConfig | null>(null);39 let keybindings = $state<KeybindingsConfig | null>(null);
33 let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);40 let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
34 let resetAllDialogOpen = $state(false);41 let resetAllDialogOpen = $state(false);
35-42+ let successMessage = $state<string | null>(null);
36- function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {43+ let conflicts = $state<KeybindingConflict[]>([]);
37- const bridge = (window as any).webkit?.messageHandlers?.dumber;
38- return bridge && typeof bridge.postMessage === "function" ? bridge : null;
39- }
40-
41- function getWebViewId(): number {
42- return (window as any).__dumber_webview_id || 0;
43- }
44 44
45 async function loadKeybindings() {45 async function loadKeybindings() {
46 loading = true;46 loading = true;
47 error = null;47 error = null;
48 48
49- const bridge = getWebKitBridge();49+ if (!getWebKitBridge()) {
50- if (!bridge) {
51 error = "WebKit bridge not available";50 error = "WebKit bridge not available";
52 loading = false;51 loading = false;
53 return;52 return;
@@ -62,53 +61,84 @@
62 loading = false;61 loading = false;
63 };62 };
64 63
65- bridge.postMessage({64+ try {
66- type: "get_keybindings",65+ postMessage({
67- webviewId: getWebViewId(),66+ type: "get_keybindings",
68- payload: {},67+ webviewId: getWebViewId(),
69- });68+ payload: {},
69+ });
70+ } catch (err) {
71+ error = err instanceof Error ? err.message : "Failed to load keybindings";
72+ loading = false;
73+ }
70 }74 }
71 75
72 function startEdit(mode: string, action: string, entry: KeybindingEntry) {76 function startEdit(mode: string, action: string, entry: KeybindingEntry) {
73 editingBinding = { mode, action, entry };77 editingBinding = { mode, action, entry };
74 }78 }
75 79
80+ function showSuccess(message: string) {
81+ successMessage = message;
82+ setTimeout(() => { successMessage = null; }, 3000);
83+ }
84+
76 async function saveBinding(keys: string[]) {85 async function saveBinding(keys: string[]) {
77- console.log("[DEBUG] saveBinding called with keys:", keys, "editingBinding:", editingBinding);
78 if (!editingBinding) {86 if (!editingBinding) {
79- console.log("[DEBUG] saveBinding: no editingBinding, returning");
80 return;87 return;
81 }88 }
82 89
83- const bridge = getWebKitBridge();90+ if (!getWebKitBridge()) {
84- console.log("[DEBUG] saveBinding: bridge =", bridge);91+ error = "WebKit bridge not available";
85- if (!bridge) {
86- console.log("[DEBUG] saveBinding: no bridge, returning");
87 return;92 return;
88 }93 }
89 94
90- (window as any).__dumber_keybinding_set = () => {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 backend
98+ if (!response || response.status !== "success") {
99+ error = "Failed to save keybinding: invalid response";
100+ console.error("[KeybindingsTab] Invalid response:", response);
101+ return;
102+ }
103+ if (response.conflicts && response.conflicts.length > 0) {
104+ conflicts = response.conflicts;
105+ } else {
106+ conflicts = [];
107+ }
91 loadKeybindings();108 loadKeybindings();
92 editingBinding = null;109 editingBinding = null;
110+ showSuccess("Keybinding saved successfully");
93 };111 };
94 (window as any).__dumber_keybinding_error = (msg: string) => {112 (window as any).__dumber_keybinding_error = (msg: string) => {
113+ console.error("[KeybindingsTab] set_keybinding error:", msg);
95 error = typeof msg === "string" ? msg : "Failed to save keybinding";114 error = typeof msg === "string" ? msg : "Failed to save keybinding";
96 };115 };
97 116
98- bridge.postMessage({117+ try {
99- type: "set_keybinding",118+ console.log("[KeybindingsTab] Sending set_keybinding:", {
100- webviewId: getWebViewId(),
101- payload: {
102 mode: editingBinding.mode,119 mode: editingBinding.mode,
103 action: editingBinding.action,120 action: editingBinding.action,
104- keys,121+ keys: keys,
105- },122+ });
106- });123+ // postMessage handles Svelte 5 Proxy serialization automatically
124+ postMessage({
125+ type: "set_keybinding",
126+ webviewId: getWebViewId(),
127+ payload: {
128+ mode: editingBinding.mode,
129+ action: editingBinding.action,
130+ keys: keys,
131+ },
132+ });
133+ console.log("[KeybindingsTab] Message posted, waiting for callback...");
134+ } catch (err) {
135+ console.error("[KeybindingsTab] postMessage failed:", err);
136+ error = err instanceof Error ? err.message : "Failed to save keybinding";
137+ }
107 }138 }
108 139
109 async function resetBinding(mode: string, action: string) {140 async function resetBinding(mode: string, action: string) {
110- const bridge = getWebKitBridge();141+ if (!getWebKitBridge()) return;
111- if (!bridge) return;
112 142
113 (window as any).__dumber_keybinding_reset = () => {143 (window as any).__dumber_keybinding_reset = () => {
114 loadKeybindings();144 loadKeybindings();
@@ -117,16 +147,19 @@
117 error = typeof msg === "string" ? msg : "Failed to reset keybinding";147 error = typeof msg === "string" ? msg : "Failed to reset keybinding";
118 };148 };
119 149
120- bridge.postMessage({150+ try {
121- type: "reset_keybinding",151+ postMessage({
122- webviewId: getWebViewId(),152+ type: "reset_keybinding",
123- payload: { mode, action },153+ webviewId: getWebViewId(),
124- });154+ payload: { mode, action },
155+ });
156+ } catch (err) {
157+ error = err instanceof Error ? err.message : "Failed to reset keybinding";
158+ }
125 }159 }
126 160
127 async function resetAllBindings() {161 async function resetAllBindings() {
128- const bridge = getWebKitBridge();162+ if (!getWebKitBridge()) return;
129- if (!bridge) return;
130 163
131 (window as any).__dumber_keybindings_reset_all = () => {164 (window as any).__dumber_keybindings_reset_all = () => {
132 loadKeybindings();165 loadKeybindings();
@@ -136,11 +169,15 @@
136 error = typeof msg === "string" ? msg : "Failed to reset keybindings";169 error = typeof msg === "string" ? msg : "Failed to reset keybindings";
137 };170 };
138 171
139- bridge.postMessage({172+ try {
140- type: "reset_all_keybindings",173+ postMessage({
141- webviewId: getWebViewId(),174+ type: "reset_all_keybindings",
142- payload: {},175+ webviewId: getWebViewId(),
143- });176+ payload: {},
177+ });
178+ } catch (err) {
179+ error = err instanceof Error ? err.message : "Failed to reset keybindings";
180+ }
144 }181 }
145 182
146 onMount(() => {183 onMount(() => {
@@ -197,7 +234,37 @@
197 <AlertTriangle class="size-4" />234 <AlertTriangle class="size-4" />
198 {error}235 {error}
199 </div>236 </div>
200- {:else if keybindings}237+ {/if}
238+ {#if successMessage}
239+ <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">
240+ <CheckCircle class="size-4" />
241+ {successMessage}
242+ </div>
243+ {/if}
244+ {#if conflicts.length > 0}
245+ <div class="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm">
246+ <div class="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
247+ <AlertTriangle class="size-4" />
248+ <span class="font-medium">Keybinding conflicts detected</span>
249+ </div>
250+ <ul class="mt-2 list-disc pl-6 text-muted-foreground">
251+ {#each conflicts as conflict}
252+ <li>
253+ <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
254+ <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
255+ </li>
256+ {/each}
257+ </ul>
258+ <button
259+ type="button"
260+ class="mt-2 text-xs text-muted-foreground hover:text-foreground"
261+ onclick={() => conflicts = []}
262+ >
263+ Dismiss
264+ </button>
265+ </div>
266+ {/if}
267+ {#if !loading && !error && keybindings}
201 {#each keybindings.groups as group (group.mode)}268 {#each keybindings.groups as group (group.mode)}
202 <KeybindingGroup269 <KeybindingGroup
203 {group}270 {group}
@@ -206,6 +273,9 @@
206 />273 />
207 {/each}274 {/each}
208 {/if}275 {/if}
276+ {#if !loading && !keybindings && !error}
277+ <div class="text-center text-muted-foreground py-8">No keybindings available</div>
278+ {/if}
209 </Card.Content>279 </Card.Content>
210 </Card.Root>280 </Card.Root>
211 281

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