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}