detached bf4fa07

fix(webui): resolve ESLint warnings across Svelte components

commit
bf4fa07bfbb79911c32a7689bf6213477705ab66
author
brice · · 2026-01-24 14:19 UTC
parent
f328a4f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +235 -156
M webui/src/lib/components/ui/color-picker/color-picker.svelte +2 -8
@@ -21,17 +21,11 @@
2121
2222 let open = $state(false);
2323
24- // svelte-awesome-color-picker uses hex binding
25- let hex = $state(value);
26-
27- // Sync hex when value changes externally
28- $effect(() => {
29- hex = value;
30- });
24+ // svelte-awesome-color-picker uses hex binding - use writable derived to sync with value
25+ let hex = $derived.by(() => value);
3126
3227 function handleInput(event: { hex: string | null }) {
3328 if (event.hex) {
34- hex = event.hex;
3529 value = event.hex;
3630 onValueChange?.(event.hex);
3731 }
@@ -21,17 +21,11 @@
21 21
22 let open = $state(false);22 let open = $state(false);
23 23
24- // svelte-awesome-color-picker uses hex binding24+ // svelte-awesome-color-picker uses hex binding - use writable derived to sync with value
25- let hex = $state(value);25+ let hex = $derived.by(() => value);
26-
27- // Sync hex when value changes externally
28- $effect(() => {
29- hex = value;
30- });
31 26
32 function handleInput(event: { hex: string | null }) {27 function handleInput(event: { hex: string | null }) {
33 if (event.hex) {28 if (event.hex) {
34- hex = event.hex;
35 value = event.hex;29 value = event.hex;
36 onValueChange?.(event.hex);30 onValueChange?.(event.hex);
37 }31 }
M webui/src/pages/ConfigPage.svelte +54 -42
@@ -14,6 +14,12 @@
1414 import * as Tabs from "$lib/components/ui/tabs";
1515 import { FlaskConical, RefreshCw } from "@lucide/svelte";
1616
17+ function getErrorMessage(e: unknown): string {
18+ if (e instanceof Error) return e.message;
19+ if (typeof e === "string") return e;
20+ return "Unknown error";
21+ }
22+
1723 type ColorPalette = {
1824 background: string;
1925 surface: string;
@@ -119,8 +125,8 @@
119125 const response = await fetch("/api/config");
120126 if (!response.ok) throw new Error("Failed to fetch config");
121127 config = (await response.json()) as ConfigDTO;
122- } catch (e: any) {
123- loadError = e.message;
128+ } catch (e: unknown) {
129+ loadError = getErrorMessage(e);
124130 console.error("[config] load failed", e);
125131 } finally {
126132 loading = false;
@@ -140,14 +146,14 @@
140146 const response = await fetch("/api/config/default");
141147 if (!response.ok) throw new Error("Failed to fetch default config");
142148 config = (await response.json()) as ConfigDTO;
143- } catch (e: any) {
149+ } catch (e: unknown) {
144150 console.error("[config] reset defaults failed", e);
145151 }
146152 resetDialogOpen = false;
147153 }
148154
149155 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
150- const bridge = (window as any).webkit?.messageHandlers?.dumber;
156+ const bridge = window.webkit?.messageHandlers?.dumber;
151157 if (bridge && typeof bridge.postMessage === "function") {
152158 return bridge;
153159 }
@@ -155,7 +161,7 @@
155161 }
156162
157163 function getWebViewId(): number {
158- return (window as any).__dumber_webview_id || 0;
164+ return window.__dumber_webview_id || 0;
159165 }
160166
161167 async function saveConfig() {
@@ -195,7 +201,7 @@
195201 }
196202 };
197203
198- (window as any).__dumber_config_saved = (resp?: unknown) => {
204+ window.__dumber_config_saved = (resp?: unknown) => {
199205 clearSaveTimeout();
200206 console.debug("[config] save success", resp);
201207 saveSuccess = true;
@@ -213,7 +219,7 @@
213219 saveSuccess = false;
214220 }, 3000);
215221 };
216- (window as any).__dumber_config_error = (msg: unknown) => {
222+ window.__dumber_config_error = (msg: unknown) => {
217223 clearSaveTimeout();
218224 console.error("[config] save error", msg);
219225 saveError = typeof msg === "string" ? msg : "Failed to save config";
@@ -228,14 +234,13 @@
228234 webview_id: webviewId,
229235 payload,
230236 });
231- console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length });
232237 } catch (postErr) {
233238 console.error("[config] postMessage threw", postErr);
234239 throw postErr;
235240 }
236- } catch (e: any) {
241+ } catch (e: unknown) {
237242 console.error("[config] save exception", e);
238- saveError = e.message;
243+ saveError = getErrorMessage(e);
239244 saving = false;
240245 }
241246 }
@@ -247,8 +252,8 @@
247252
248253 onMount(() => {
249254 console.debug("[config] mount", {
250- webviewId: (window as any).__dumber_webview_id,
251- hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber),
255+ webviewId: window.__dumber_webview_id,
256+ hasBridge: Boolean(window.webkit?.messageHandlers?.dumber),
252257 });
253258
254259 const onThemeChanged = (e: Event) => {
@@ -342,7 +347,12 @@
342347 </div>
343348 <div class="space-y-2">
344349 <Label for="ui_scale">UI Scale</Label>
345- <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} />
350+ <Input
351+ id="ui_scale"
352+ type="number"
353+ step="0.1"
354+ bind:value={config.default_ui_scale}
355+ />
346356 </div>
347357 </div>
348358
@@ -620,35 +630,37 @@
620630 <span>Theme x{themeEvents}</span>
621631 {/if}
622632 </div>
623- <div class="flex items-center gap-3">
624- <AlertDialog.Root bind:open={resetDialogOpen}>
625- <AlertDialog.Trigger disabled={saving}>
626- {#snippet child({ props })}
627- <Button variant="outline" {...props} type="button">
628- Reset Defaults
629- </Button>
630- {/snippet}
631- </AlertDialog.Trigger>
632- <AlertDialog.Content>
633- <AlertDialog.Header>
634- <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
635- <AlertDialog.Description>
636- This will reset all settings on this page to their default values.
637- You will still need to click Save to persist the changes.
638- </AlertDialog.Description>
639- </AlertDialog.Header>
640- <AlertDialog.Footer>
641- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
642- <AlertDialog.Action onclick={doResetToDefaults}>
643- Reset Defaults
644- </AlertDialog.Action>
645- </AlertDialog.Footer>
646- </AlertDialog.Content>
647- </AlertDialog.Root>
648- <Button onclick={saveConfig} disabled={saving} type="button">
649- {saving ? "Saving…" : "Save"}
650- </Button>
651- </div>
633+ {#if activeTab !== "keybindings"}
634+ <div class="flex items-center gap-3">
635+ <AlertDialog.Root bind:open={resetDialogOpen}>
636+ <AlertDialog.Trigger disabled={saving}>
637+ {#snippet child({ props })}
638+ <Button variant="outline" {...props} type="button">
639+ Reset Defaults
640+ </Button>
641+ {/snippet}
642+ </AlertDialog.Trigger>
643+ <AlertDialog.Content>
644+ <AlertDialog.Header>
645+ <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
646+ <AlertDialog.Description>
647+ This will reset all settings on this page to their default values.
648+ You will still need to click Save to persist the changes.
649+ </AlertDialog.Description>
650+ </AlertDialog.Header>
651+ <AlertDialog.Footer>
652+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
653+ <AlertDialog.Action onclick={doResetToDefaults}>
654+ Reset Defaults
655+ </AlertDialog.Action>
656+ </AlertDialog.Footer>
657+ </AlertDialog.Content>
658+ </AlertDialog.Root>
659+ <Button onclick={saveConfig} disabled={saving} type="button">
660+ {saving ? "Saving…" : "Save"}
661+ </Button>
662+ </div>
663+ {/if}
652664 </div>
653665
654666 <!-- Save error display -->
@@ -14,6 +14,12 @@
14 import * as Tabs from "$lib/components/ui/tabs";14 import * as Tabs from "$lib/components/ui/tabs";
15 import { FlaskConical, RefreshCw } from "@lucide/svelte";15 import { FlaskConical, RefreshCw } from "@lucide/svelte";
16 16
17+ function getErrorMessage(e: unknown): string {
18+ if (e instanceof Error) return e.message;
19+ if (typeof e === "string") return e;
20+ return "Unknown error";
21+ }
22+
17 type ColorPalette = {23 type ColorPalette = {
18 background: string;24 background: string;
19 surface: string;25 surface: string;
@@ -119,8 +125,8 @@
119 const response = await fetch("/api/config");125 const response = await fetch("/api/config");
120 if (!response.ok) throw new Error("Failed to fetch config");126 if (!response.ok) throw new Error("Failed to fetch config");
121 config = (await response.json()) as ConfigDTO;127 config = (await response.json()) as ConfigDTO;
122- } catch (e: any) {128+ } catch (e: unknown) {
123- loadError = e.message;129+ loadError = getErrorMessage(e);
124 console.error("[config] load failed", e);130 console.error("[config] load failed", e);
125 } finally {131 } finally {
126 loading = false;132 loading = false;
@@ -140,14 +146,14 @@
140 const response = await fetch("/api/config/default");146 const response = await fetch("/api/config/default");
141 if (!response.ok) throw new Error("Failed to fetch default config");147 if (!response.ok) throw new Error("Failed to fetch default config");
142 config = (await response.json()) as ConfigDTO;148 config = (await response.json()) as ConfigDTO;
143- } catch (e: any) {149+ } catch (e: unknown) {
144 console.error("[config] reset defaults failed", e);150 console.error("[config] reset defaults failed", e);
145 }151 }
146 resetDialogOpen = false;152 resetDialogOpen = false;
147 }153 }
148 154
149 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {155 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
150- const bridge = (window as any).webkit?.messageHandlers?.dumber;156+ const bridge = window.webkit?.messageHandlers?.dumber;
151 if (bridge && typeof bridge.postMessage === "function") {157 if (bridge && typeof bridge.postMessage === "function") {
152 return bridge;158 return bridge;
153 }159 }
@@ -155,7 +161,7 @@
155 }161 }
156 162
157 function getWebViewId(): number {163 function getWebViewId(): number {
158- return (window as any).__dumber_webview_id || 0;164+ return window.__dumber_webview_id || 0;
159 }165 }
160 166
161 async function saveConfig() {167 async function saveConfig() {
@@ -195,7 +201,7 @@
195 }201 }
196 };202 };
197 203
198- (window as any).__dumber_config_saved = (resp?: unknown) => {204+ window.__dumber_config_saved = (resp?: unknown) => {
199 clearSaveTimeout();205 clearSaveTimeout();
200 console.debug("[config] save success", resp);206 console.debug("[config] save success", resp);
201 saveSuccess = true;207 saveSuccess = true;
@@ -213,7 +219,7 @@
213 saveSuccess = false;219 saveSuccess = false;
214 }, 3000);220 }, 3000);
215 };221 };
216- (window as any).__dumber_config_error = (msg: unknown) => {222+ window.__dumber_config_error = (msg: unknown) => {
217 clearSaveTimeout();223 clearSaveTimeout();
218 console.error("[config] save error", msg);224 console.error("[config] save error", msg);
219 saveError = typeof msg === "string" ? msg : "Failed to save config";225 saveError = typeof msg === "string" ? msg : "Failed to save config";
@@ -228,14 +234,13 @@
228 webview_id: webviewId,234 webview_id: webviewId,
229 payload,235 payload,
230 });236 });
231- console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length });
232 } catch (postErr) {237 } catch (postErr) {
233 console.error("[config] postMessage threw", postErr);238 console.error("[config] postMessage threw", postErr);
234 throw postErr;239 throw postErr;
235 }240 }
236- } catch (e: any) {241+ } catch (e: unknown) {
237 console.error("[config] save exception", e);242 console.error("[config] save exception", e);
238- saveError = e.message;243+ saveError = getErrorMessage(e);
239 saving = false;244 saving = false;
240 }245 }
241 }246 }
@@ -247,8 +252,8 @@
247 252
248 onMount(() => {253 onMount(() => {
249 console.debug("[config] mount", {254 console.debug("[config] mount", {
250- webviewId: (window as any).__dumber_webview_id,255+ webviewId: window.__dumber_webview_id,
251- hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber),256+ hasBridge: Boolean(window.webkit?.messageHandlers?.dumber),
252 });257 });
253 258
254 const onThemeChanged = (e: Event) => {259 const onThemeChanged = (e: Event) => {
@@ -342,7 +347,12 @@
342 </div>347 </div>
343 <div class="space-y-2">348 <div class="space-y-2">
344 <Label for="ui_scale">UI Scale</Label>349 <Label for="ui_scale">UI Scale</Label>
345- <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} />350+ <Input
351+ id="ui_scale"
352+ type="number"
353+ step="0.1"
354+ bind:value={config.default_ui_scale}
355+ />
346 </div>356 </div>
347 </div>357 </div>
348 358
@@ -620,35 +630,37 @@
620 <span>Theme x{themeEvents}</span>630 <span>Theme x{themeEvents}</span>
621 {/if}631 {/if}
622 </div>632 </div>
623- <div class="flex items-center gap-3">633+ {#if activeTab !== "keybindings"}
624- <AlertDialog.Root bind:open={resetDialogOpen}>634+ <div class="flex items-center gap-3">
625- <AlertDialog.Trigger disabled={saving}>635+ <AlertDialog.Root bind:open={resetDialogOpen}>
626- {#snippet child({ props })}636+ <AlertDialog.Trigger disabled={saving}>
627- <Button variant="outline" {...props} type="button">637+ {#snippet child({ props })}
628- Reset Defaults638+ <Button variant="outline" {...props} type="button">
629- </Button>639+ Reset Defaults
630- {/snippet}640+ </Button>
631- </AlertDialog.Trigger>641+ {/snippet}
632- <AlertDialog.Content>642+ </AlertDialog.Trigger>
633- <AlertDialog.Header>643+ <AlertDialog.Content>
634- <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>644+ <AlertDialog.Header>
635- <AlertDialog.Description>645+ <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
636- This will reset all settings on this page to their default values.646+ <AlertDialog.Description>
637- You will still need to click Save to persist the changes.647+ This will reset all settings on this page to their default values.
638- </AlertDialog.Description>648+ You will still need to click Save to persist the changes.
639- </AlertDialog.Header>649+ </AlertDialog.Description>
640- <AlertDialog.Footer>650+ </AlertDialog.Header>
641- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>651+ <AlertDialog.Footer>
642- <AlertDialog.Action onclick={doResetToDefaults}>652+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
643- Reset Defaults653+ <AlertDialog.Action onclick={doResetToDefaults}>
644- </AlertDialog.Action>654+ Reset Defaults
645- </AlertDialog.Footer>655+ </AlertDialog.Action>
646- </AlertDialog.Content>656+ </AlertDialog.Footer>
647- </AlertDialog.Root>657+ </AlertDialog.Content>
648- <Button onclick={saveConfig} disabled={saving} type="button">658+ </AlertDialog.Root>
649- {saving ? "Saving…" : "Save"}659+ <Button onclick={saveConfig} disabled={saving} type="button">
650- </Button>660+ {saving ? "Saving…" : "Save"}
651- </div>661+ </Button>
662+ </div>
663+ {/if}
652 </div>664 </div>
653 665
654 <!-- Save error display -->666 <!-- Save error display -->
M webui/src/pages/ErrorPage.svelte +11 -2
@@ -70,7 +70,11 @@
7070 const handler = (event: MessageEvent) => {
7171 if (event.data?.requestId === requestId) {
7272 window.removeEventListener('message', handler);
73- event.data.success ? resolve(event.data.data) : reject(new Error(event.data.error || 'Unknown error'));
73+ if (event.data.success) {
74+ resolve(event.data.data);
75+ } else {
76+ reject(new Error(event.data.error || 'Unknown error'));
77+ }
7478 }
7579 };
7680 window.addEventListener('message', handler);
@@ -110,7 +114,11 @@
110114 }
111115
112116 function retry() {
113- targetUrl ? window.location.href = targetUrl : window.location.reload();
117+ if (targetUrl) {
118+ window.location.href = targetUrl;
119+ } else {
120+ window.location.reload();
121+ }
114122 }
115123
116124 function goBack() {
@@ -144,6 +152,7 @@
144152 <Card.Header class="text-center">
145153 <div class="flex justify-center mb-4">
146154 <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
155+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths -->
147156 {@html icons[config.icon] || icons.error}
148157 </svg>
149158 </div>
@@ -70,7 +70,11 @@
70 const handler = (event: MessageEvent) => {70 const handler = (event: MessageEvent) => {
71 if (event.data?.requestId === requestId) {71 if (event.data?.requestId === requestId) {
72 window.removeEventListener('message', handler);72 window.removeEventListener('message', handler);
73- event.data.success ? resolve(event.data.data) : reject(new Error(event.data.error || 'Unknown error'));73+ if (event.data.success) {
74+ resolve(event.data.data);
75+ } else {
76+ reject(new Error(event.data.error || 'Unknown error'));
77+ }
74 }78 }
75 };79 };
76 window.addEventListener('message', handler);80 window.addEventListener('message', handler);
@@ -110,7 +114,11 @@
110 }114 }
111 115
112 function retry() {116 function retry() {
113- targetUrl ? window.location.href = targetUrl : window.location.reload();117+ if (targetUrl) {
118+ window.location.href = targetUrl;
119+ } else {
120+ window.location.reload();
121+ }
114 }122 }
115 123
116 function goBack() {124 function goBack() {
@@ -144,6 +152,7 @@
144 <Card.Header class="text-center">152 <Card.Header class="text-center">
145 <div class="flex justify-center mb-4">153 <div class="flex justify-center mb-4">
146 <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">154 <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
155+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths -->
147 {@html icons[config.icon] || icons.error}156 {@html icons[config.icon] || icons.error}
148 </svg>157 </svg>
149 </div>158 </div>
M webui/src/pages/config/ConfigShell.svelte +3 -4
@@ -9,14 +9,12 @@
99 let { children }: Props = $props();
1010
1111 function navigateWithViewTransition(url: string) {
12- const doc = document as any;
13-
14- if (typeof doc?.startViewTransition !== 'function') {
12+ if (typeof document.startViewTransition !== 'function') {
1513 window.location.href = url;
1614 return;
1715 }
1816
19- const transition = doc.startViewTransition(() => {
17+ const transition = document.startViewTransition(() => {
2018 document.documentElement.dataset.vt = 'leaving';
2119 });
2220
@@ -34,6 +32,7 @@
3432 <svelte:head>
3533 <title>dumb://config</title>
3634 <meta name="description" content="Dumber Browser - Settings" />
35+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
3736 {@html `<style>
3837 html, body { margin: 0; padding: 0; }
3938 html { background: var(--background, #0a0a0a); }
@@ -9,14 +9,12 @@
9 let { children }: Props = $props();9 let { children }: Props = $props();
10 10
11 function navigateWithViewTransition(url: string) {11 function navigateWithViewTransition(url: string) {
12- const doc = document as any;12+ if (typeof document.startViewTransition !== 'function') {
13-
14- if (typeof doc?.startViewTransition !== 'function') {
15 window.location.href = url;13 window.location.href = url;
16 return;14 return;
17 }15 }
18 16
19- const transition = doc.startViewTransition(() => {17+ const transition = document.startViewTransition(() => {
20 document.documentElement.dataset.vt = 'leaving';18 document.documentElement.dataset.vt = 'leaving';
21 });19 });
22 20
@@ -34,6 +32,7 @@
34 <svelte:head>32 <svelte:head>
35 <title>dumb://config</title>33 <title>dumb://config</title>
36 <meta name="description" content="Dumber Browser - Settings" />34 <meta name="description" content="Dumber Browser - Settings" />
35+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
37 {@html `<style>36 {@html `<style>
38 html, body { margin: 0; padding: 0; }37 html, body { margin: 0; padding: 0; }
39 html { background: var(--background, #0a0a0a); }38 html { background: var(--background, #0a0a0a); }
M webui/src/pages/config/KeyCaptureModal.svelte +16 -13
@@ -21,10 +21,10 @@
2121
2222 let { binding, onSave, onCancel }: Props = $props();
2323
24- // Initialize with a plain copy of the binding keys (not a Proxy reference)
25- // untrack() tells Svelte we intentionally want the initial value only
24+ // Initialize with a plain copy of the binding keys using $state.snapshot()
25+ // to strip Svelte 5 Proxy wrappers. untrack() ensures we only read the initial value.
2626 let newKeys = $state<string[]>(
27- untrack(() => JSON.parse(JSON.stringify(binding.keys)) as string[])
27+ untrack(() => $state.snapshot(binding.keys) as string[])
2828 );
2929 let isCapturing = $state(false);
3030 let capturedKey = $state<string | null>(null);
@@ -93,6 +93,17 @@
9393 newKeys = newKeys.filter((_, i) => i !== index);
9494 }
9595
96+ function handleSave() {
97+ // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers
98+ const keysToSave = $state.snapshot(newKeys) as string[];
99+ // Auto-add any pending captured key before saving
100+ if (capturedKey && !keysToSave.includes(capturedKey)) {
101+ keysToSave.push(capturedKey);
102+ }
103+ isSaving = true;
104+ onSave(keysToSave);
105+ }
106+
96107 onMount(() => {
97108 const handler = (e: KeyboardEvent) => handleKeyDown(e);
98109 window.addEventListener("keydown", handler, true);
@@ -116,7 +127,7 @@
116127 Current Bindings
117128 </div>
118129 <div class="flex flex-wrap gap-2">
119- {#each newKeys as key, i}
130+ {#each newKeys as key, i (key)}
120131 <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
121132 <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
122133 <button
@@ -173,15 +184,7 @@
173184
174185 <AlertDialog.Footer>
175186 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
176- <AlertDialog.Action onclick={() => {
177- // Auto-add any pending captured key before saving
178- let keysToSave = [...newKeys];
179- if (capturedKey && !keysToSave.includes(capturedKey)) {
180- keysToSave.push(capturedKey);
181- }
182- isSaving = true;
183- onSave(keysToSave);
184- }}>Save</AlertDialog.Action>
187+ <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action>
185188 </AlertDialog.Footer>
186189 </AlertDialog.Content>
187190 </AlertDialog.Root>
@@ -21,10 +21,10 @@
21 21
22 let { binding, onSave, onCancel }: Props = $props();22 let { binding, onSave, onCancel }: Props = $props();
23 23
24- // Initialize with a plain copy of the binding keys (not a Proxy reference)24+ // Initialize with a plain copy of the binding keys using $state.snapshot()
25- // untrack() tells Svelte we intentionally want the initial value only25+ // to strip Svelte 5 Proxy wrappers. untrack() ensures we only read the initial value.
26 let newKeys = $state<string[]>(26 let newKeys = $state<string[]>(
27- untrack(() => JSON.parse(JSON.stringify(binding.keys)) as string[])27+ untrack(() => $state.snapshot(binding.keys) as string[])
28 );28 );
29 let isCapturing = $state(false);29 let isCapturing = $state(false);
30 let capturedKey = $state<string | null>(null);30 let capturedKey = $state<string | null>(null);
@@ -93,6 +93,17 @@
93 newKeys = newKeys.filter((_, i) => i !== index);93 newKeys = newKeys.filter((_, i) => i !== index);
94 }94 }
95 95
96+ function handleSave() {
97+ // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers
98+ const keysToSave = $state.snapshot(newKeys) as string[];
99+ // Auto-add any pending captured key before saving
100+ if (capturedKey && !keysToSave.includes(capturedKey)) {
101+ keysToSave.push(capturedKey);
102+ }
103+ isSaving = true;
104+ onSave(keysToSave);
105+ }
106+
96 onMount(() => {107 onMount(() => {
97 const handler = (e: KeyboardEvent) => handleKeyDown(e);108 const handler = (e: KeyboardEvent) => handleKeyDown(e);
98 window.addEventListener("keydown", handler, true);109 window.addEventListener("keydown", handler, true);
@@ -116,7 +127,7 @@
116 Current Bindings127 Current Bindings
117 </div>128 </div>
118 <div class="flex flex-wrap gap-2">129 <div class="flex flex-wrap gap-2">
119- {#each newKeys as key, i}130+ {#each newKeys as key, i (key)}
120 <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">131 <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
121 <kbd class="text-xs font-mono">{formatKey(key)}</kbd>132 <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
122 <button133 <button
@@ -173,15 +184,7 @@
173 184
174 <AlertDialog.Footer>185 <AlertDialog.Footer>
175 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>186 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
176- <AlertDialog.Action onclick={() => {187+ <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action>
177- // Auto-add any pending captured key before saving
178- let keysToSave = [...newKeys];
179- if (capturedKey && !keysToSave.includes(capturedKey)) {
180- keysToSave.push(capturedKey);
181- }
182- isSaving = true;
183- onSave(keysToSave);
184- }}>Save</AlertDialog.Action>
185 </AlertDialog.Footer>188 </AlertDialog.Footer>
186 </AlertDialog.Content>189 </AlertDialog.Content>
187 </AlertDialog.Root>190 </AlertDialog.Root>
M webui/src/pages/config/KeybindingGroup.svelte +1 -1
@@ -56,7 +56,7 @@
5656 <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell>
5757 <Table.Cell>
5858 <div class="flex flex-wrap gap-1">
59- {#each binding.keys as key}
59+ {#each binding.keys as key (key)}
6060 <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
6161 {formatKey(key)}
6262 </kbd>
@@ -56,7 +56,7 @@
56 <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell>56 <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell>
57 <Table.Cell>57 <Table.Cell>
58 <div class="flex flex-wrap gap-1">58 <div class="flex flex-wrap gap-1">
59- {#each binding.keys as key}59+ {#each binding.keys as key (key)}
60 <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">60 <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
61 {formatKey(key)}61 {formatKey(key)}
62 </kbd>62 </kbd>
M webui/src/pages/config/KeybindingsTab.svelte +129 -70
@@ -41,6 +41,13 @@
4141 let resetAllDialogOpen = $state(false);
4242 let successMessage = $state<string | null>(null);
4343 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+ }
4451
4552 async function loadKeybindings() {
4653 loading = true;
@@ -52,14 +59,14 @@
5259 return;
5360 }
5461
55- (window as any).__dumber_keybindings_loaded = (data: KeybindingsConfig) => {
62+ setupCallback<KeybindingsConfig>("__dumber_keybindings_loaded", (data) => {
5663 keybindings = data;
5764 loading = false;
58- };
59- (window as any).__dumber_keybindings_error = (msg: string) => {
65+ });
66+ setupCallback<string>("__dumber_keybindings_error", (msg) => {
6067 error = typeof msg === "string" ? msg : "Failed to load keybindings";
6168 loading = false;
62- };
69+ });
6370
6471 try {
6572 postMessage({
@@ -83,45 +90,50 @@
8390 }
8491
8592 async function saveBinding(keys: string[]) {
86- if (!editingBinding) {
87- return;
88- }
93+ if (!editingBinding) return;
8994
9095 if (!getWebKitBridge()) {
9196 error = "WebKit bridge not available";
9297 return;
9398 }
9499
95- (window as any).__dumber_keybinding_set = (response: { status: string; conflicts?: KeybindingConflict[] }) => {
96- // Verify we got a valid success response from backend
97- if (!response || response.status !== "success") {
98- error = "Failed to save keybinding: invalid response";
99- return;
100- }
101- if (response.conflicts && response.conflicts.length > 0) {
102- conflicts = response.conflicts;
103- } else {
104- conflicts = [];
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");
105117 }
106- loadKeybindings();
107- editingBinding = null;
108- showSuccess("Keybinding saved successfully");
109- };
110- (window as any).__dumber_keybinding_set_error = (msg: string) => {
118+ );
119+ setupCallback<string>("__dumber_keybinding_set_error", (msg) => {
120+ clearTimeout(timeoutId);
111121 error = typeof msg === "string" ? msg : "Failed to save keybinding";
112- };
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] };
113128
114129 try {
115130 postMessage({
116131 type: "set_keybinding",
117132 webviewId: getWebViewId(),
118- payload: {
119- mode: editingBinding.mode,
120- action: editingBinding.action,
121- keys: keys,
122- },
133+ payload,
123134 });
124135 } catch (err) {
136+ clearTimeout(timeoutId);
125137 error = err instanceof Error ? err.message : "Failed to save keybinding";
126138 }
127139 }
@@ -129,14 +141,14 @@
129141 async function resetBinding(mode: string, action: string) {
130142 if (!getWebKitBridge()) return;
131143
132- (window as any).__dumber_keybinding_reset = () => {
133- conflicts = []; // Clear conflicts after reset
144+ setupCallback<void>("__dumber_keybinding_reset", () => {
145+ conflicts = [];
134146 loadKeybindings();
135147 showSuccess("Keybinding reset to default");
136- };
137- (window as any).__dumber_keybinding_reset_error = (msg: string) => {
148+ });
149+ setupCallback<string>("__dumber_keybinding_reset_error", (msg) => {
138150 error = typeof msg === "string" ? msg : "Failed to reset keybinding";
139- };
151+ });
140152
141153 try {
142154 postMessage({
@@ -149,18 +161,43 @@
149161 }
150162 }
151163
152- async function resetAllBindings() {
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() {
153187 if (!getWebKitBridge()) return;
154188
155- (window as any).__dumber_keybindings_reset_all = () => {
156- conflicts = []; // Clear conflicts after reset all
189+ savingReset = true;
190+
191+ setupCallback<void>("__dumber_keybindings_reset_all", () => {
192+ hasPendingReset = false;
193+ savingReset = false;
157194 loadKeybindings();
158- resetAllDialogOpen = false;
159- showSuccess("All keybindings reset to defaults");
160- };
161- (window as any).__dumber_keybindings_reset_all_error = (msg: string) => {
162- error = typeof msg === "string" ? msg : "Failed to reset keybindings";
163- };
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+ });
164201
165202 try {
166203 postMessage({
@@ -169,10 +206,17 @@
169206 payload: {},
170207 });
171208 } catch (err) {
172- error = err instanceof Error ? err.message : "Failed to reset keybindings";
209+ error = err instanceof Error ? err.message : "Failed to save keybindings";
210+ savingReset = false;
173211 }
174212 }
175213
214+ // Cancel pending reset and reload from backend
215+ function cancelReset() {
216+ hasPendingReset = false;
217+ loadKeybindings();
218+ }
219+
176220 onMount(() => {
177221 loadKeybindings();
178222 });
@@ -188,36 +232,51 @@
188232 </Card.Description>
189233 </div>
190234 <div class="flex gap-2">
191- <Button variant="outline" size="sm" onclick={loadKeybindings}>
192- <RefreshCw class="mr-1 size-4" />
193- Refresh
194- </Button>
195- <AlertDialog.Root bind:open={resetAllDialogOpen}>
196- <AlertDialog.Trigger>
197- {#snippet child({ props })}
198- <Button variant="outline" size="sm" {...props}>
199- <RotateCcw class="mr-1 size-4" />
200- Reset All
201- </Button>
202- {/snippet}
203- </AlertDialog.Trigger>
204- <AlertDialog.Content>
205- <AlertDialog.Header>
206- <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
207- <AlertDialog.Description>
208- This will restore all keybindings to their default values. This action cannot be undone.
209- </AlertDialog.Description>
210- </AlertDialog.Header>
211- <AlertDialog.Footer>
212- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
213- <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>
214- </AlertDialog.Footer>
215- </AlertDialog.Content>
216- </AlertDialog.Root>
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}
217270 </div>
218271 </div>
219272 </Card.Header>
220273 <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}
221280 {#if loading}
222281 <div class="flex items-center justify-center py-8">
223282 <Spinner class="size-6" />
@@ -241,7 +300,7 @@
241300 <span class="font-medium">Keybinding conflicts detected</span>
242301 </div>
243302 <ul class="mt-2 list-disc pl-6 text-muted-foreground">
244- {#each conflicts as conflict}
303+ {#each conflicts as conflict (conflict.key + conflict.conflicting_action)}
245304 <li>
246305 <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
247306 <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
@@ -41,6 +41,13 @@
41 let resetAllDialogOpen = $state(false);41 let resetAllDialogOpen = $state(false);
42 let successMessage = $state<string | null>(null);42 let successMessage = $state<string | null>(null);
43 let conflicts = $state<KeybindingConflict[]>([]);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+ }
44 51
45 async function loadKeybindings() {52 async function loadKeybindings() {
46 loading = true;53 loading = true;
@@ -52,14 +59,14 @@
52 return;59 return;
53 }60 }
54 61
55- (window as any).__dumber_keybindings_loaded = (data: KeybindingsConfig) => {62+ setupCallback<KeybindingsConfig>("__dumber_keybindings_loaded", (data) => {
56 keybindings = data;63 keybindings = data;
57 loading = false;64 loading = false;
58- };65+ });
59- (window as any).__dumber_keybindings_error = (msg: string) => {66+ setupCallback<string>("__dumber_keybindings_error", (msg) => {
60 error = typeof msg === "string" ? msg : "Failed to load keybindings";67 error = typeof msg === "string" ? msg : "Failed to load keybindings";
61 loading = false;68 loading = false;
62- };69+ });
63 70
64 try {71 try {
65 postMessage({72 postMessage({
@@ -83,45 +90,50 @@
83 }90 }
84 91
85 async function saveBinding(keys: string[]) {92 async function saveBinding(keys: string[]) {
86- if (!editingBinding) {93+ if (!editingBinding) return;
87- return;
88- }
89 94
90 if (!getWebKitBridge()) {95 if (!getWebKitBridge()) {
91 error = "WebKit bridge not available";96 error = "WebKit bridge not available";
92 return;97 return;
93 }98 }
94 99
95- (window as any).__dumber_keybinding_set = (response: { status: string; conflicts?: KeybindingConflict[] }) => {100+ // Timeout to detect if backend never responds (message silently dropped)
96- // Verify we got a valid success response from backend101+ const timeoutId = setTimeout(() => {
97- if (!response || response.status !== "success") {102+ error = "Save timed out - backend did not respond";
98- error = "Failed to save keybinding: invalid response";103+ }, 5000);
99- return;104+
100- }105+ setupCallback<{ status: string; conflicts?: KeybindingConflict[] }>(
101- if (response.conflicts && response.conflicts.length > 0) {106+ "__dumber_keybinding_set",
102- conflicts = response.conflicts;107+ (response) => {
103- } else {108+ clearTimeout(timeoutId);
104- conflicts = [];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");
105 }117 }
106- loadKeybindings();118+ );
107- editingBinding = null;119+ setupCallback<string>("__dumber_keybinding_set_error", (msg) => {
108- showSuccess("Keybinding saved successfully");120+ clearTimeout(timeoutId);
109- };
110- (window as any).__dumber_keybinding_set_error = (msg: string) => {
111 error = typeof msg === "string" ? msg : "Failed to save keybinding";121 error = typeof msg === "string" ? msg : "Failed to save keybinding";
112- };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] };
113 128
114 try {129 try {
115 postMessage({130 postMessage({
116 type: "set_keybinding",131 type: "set_keybinding",
117 webviewId: getWebViewId(),132 webviewId: getWebViewId(),
118- payload: {133+ payload,
119- mode: editingBinding.mode,
120- action: editingBinding.action,
121- keys: keys,
122- },
123 });134 });
124 } catch (err) {135 } catch (err) {
136+ clearTimeout(timeoutId);
125 error = err instanceof Error ? err.message : "Failed to save keybinding";137 error = err instanceof Error ? err.message : "Failed to save keybinding";
126 }138 }
127 }139 }
@@ -129,14 +141,14 @@
129 async function resetBinding(mode: string, action: string) {141 async function resetBinding(mode: string, action: string) {
130 if (!getWebKitBridge()) return;142 if (!getWebKitBridge()) return;
131 143
132- (window as any).__dumber_keybinding_reset = () => {144+ setupCallback<void>("__dumber_keybinding_reset", () => {
133- conflicts = []; // Clear conflicts after reset145+ conflicts = [];
134 loadKeybindings();146 loadKeybindings();
135 showSuccess("Keybinding reset to default");147 showSuccess("Keybinding reset to default");
136- };148+ });
137- (window as any).__dumber_keybinding_reset_error = (msg: string) => {149+ setupCallback<string>("__dumber_keybinding_reset_error", (msg) => {
138 error = typeof msg === "string" ? msg : "Failed to reset keybinding";150 error = typeof msg === "string" ? msg : "Failed to reset keybinding";
139- };151+ });
140 152
141 try {153 try {
142 postMessage({154 postMessage({
@@ -149,18 +161,43 @@
149 }161 }
150 }162 }
151 163
152- async function resetAllBindings() {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() {
153 if (!getWebKitBridge()) return;187 if (!getWebKitBridge()) return;
154 188
155- (window as any).__dumber_keybindings_reset_all = () => {189+ savingReset = true;
156- conflicts = []; // Clear conflicts after reset all190+
191+ setupCallback<void>("__dumber_keybindings_reset_all", () => {
192+ hasPendingReset = false;
193+ savingReset = false;
157 loadKeybindings();194 loadKeybindings();
158- resetAllDialogOpen = false;195+ showSuccess("All keybindings saved successfully");
159- showSuccess("All keybindings reset to defaults");196+ });
160- };197+ setupCallback<string>("__dumber_keybindings_reset_all_error", (msg) => {
161- (window as any).__dumber_keybindings_reset_all_error = (msg: string) => {198+ error = typeof msg === "string" ? msg : "Failed to save keybindings";
162- error = typeof msg === "string" ? msg : "Failed to reset keybindings";199+ savingReset = false;
163- };200+ });
164 201
165 try {202 try {
166 postMessage({203 postMessage({
@@ -169,10 +206,17 @@
169 payload: {},206 payload: {},
170 });207 });
171 } catch (err) {208 } catch (err) {
172- error = err instanceof Error ? err.message : "Failed to reset keybindings";209+ error = err instanceof Error ? err.message : "Failed to save keybindings";
210+ savingReset = false;
173 }211 }
174 }212 }
175 213
214+ // Cancel pending reset and reload from backend
215+ function cancelReset() {
216+ hasPendingReset = false;
217+ loadKeybindings();
218+ }
219+
176 onMount(() => {220 onMount(() => {
177 loadKeybindings();221 loadKeybindings();
178 });222 });
@@ -188,36 +232,51 @@
188 </Card.Description>232 </Card.Description>
189 </div>233 </div>
190 <div class="flex gap-2">234 <div class="flex gap-2">
191- <Button variant="outline" size="sm" onclick={loadKeybindings}>235+ {#if hasPendingReset}
192- <RefreshCw class="mr-1 size-4" />236+ <Button variant="outline" size="sm" onclick={cancelReset} disabled={savingReset}>
193- Refresh237+ Cancel
194- </Button>238+ </Button>
195- <AlertDialog.Root bind:open={resetAllDialogOpen}>239+ <Button size="sm" onclick={saveResetAll} disabled={savingReset}>
196- <AlertDialog.Trigger>240+ {savingReset ? "Saving…" : "Save"}
197- {#snippet child({ props })}241+ </Button>
198- <Button variant="outline" size="sm" {...props}>242+ {:else}
199- <RotateCcw class="mr-1 size-4" />243+ <Button variant="outline" size="sm" onclick={loadKeybindings}>
200- Reset All244+ <RefreshCw class="mr-1 size-4" />
201- </Button>245+ Refresh
202- {/snippet}246+ </Button>
203- </AlertDialog.Trigger>247+ <AlertDialog.Root bind:open={resetAllDialogOpen}>
204- <AlertDialog.Content>248+ <AlertDialog.Trigger>
205- <AlertDialog.Header>249+ {#snippet child({ props })}
206- <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>250+ <Button variant="outline" size="sm" {...props}>
207- <AlertDialog.Description>251+ <RotateCcw class="mr-1 size-4" />
208- This will restore all keybindings to their default values. This action cannot be undone.252+ Reset All
209- </AlertDialog.Description>253+ </Button>
210- </AlertDialog.Header>254+ {/snippet}
211- <AlertDialog.Footer>255+ </AlertDialog.Trigger>
212- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>256+ <AlertDialog.Content>
213- <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>257+ <AlertDialog.Header>
214- </AlertDialog.Footer>258+ <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
215- </AlertDialog.Content>259+ <AlertDialog.Description>
216- </AlertDialog.Root>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}
217 </div>270 </div>
218 </div>271 </div>
219 </Card.Header>272 </Card.Header>
220 <Card.Content class="space-y-6">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}
221 {#if loading}280 {#if loading}
222 <div class="flex items-center justify-center py-8">281 <div class="flex items-center justify-center py-8">
223 <Spinner class="size-6" />282 <Spinner class="size-6" />
@@ -241,7 +300,7 @@
241 <span class="font-medium">Keybinding conflicts detected</span>300 <span class="font-medium">Keybinding conflicts detected</span>
242 </div>301 </div>
243 <ul class="mt-2 list-disc pl-6 text-muted-foreground">302 <ul class="mt-2 list-disc pl-6 text-muted-foreground">
244- {#each conflicts as conflict}303+ {#each conflicts as conflict (conflict.key + conflict.conflicting_action)}
245 <li>304 <li>
246 <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to305 <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
247 <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode306 <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
M webui/src/pages/homepage/analytics/HourlyActivityChart.svelte +1 -0
@@ -19,6 +19,7 @@
1919
2020 // Convert data to Frappe Charts format (ensure all 24 hours)
2121 const chartData = $derived.by(() => {
22+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation
2223 const visitsByHour = new Map<number, number>();
2324 hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));
2425
@@ -19,6 +19,7 @@
19 19
20 // Convert data to Frappe Charts format (ensure all 24 hours)20 // Convert data to Frappe Charts format (ensure all 24 hours)
21 const chartData = $derived.by(() => {21 const chartData = $derived.by(() => {
22+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation
22 const visitsByHour = new Map<number, number>();23 const visitsByHour = new Map<number, number>();
23 hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));24 hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));
24 25
M webui/src/pages/homepage/favorites/FavoriteEditor.svelte +1 -1
@@ -139,7 +139,7 @@
139139 <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label>
140140 <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}>
141141 <option value={null}>— None —</option>
142- {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key}
142+ {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key (key)}
143143 <option value={key}>{key}</option>
144144 {/each}
145145 </select>
@@ -139,7 +139,7 @@
139 <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label>139 <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label>
140 <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}>140 <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}>
141 <option value={null}>— None —</option>141 <option value={null}>— None —</option>
142- {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key}142+ {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key (key)}
143 <option value={key}>{key}</option>143 <option value={key}>{key}</option>
144 {/each}144 {/each}
145 </select>145 </select>
M webui/src/pages/homepage/panels/HistoryPanel.svelte +2 -0
@@ -37,6 +37,7 @@
3737 });
3838
3939 // Recompute timeline from filtered entries
40+ /* eslint-disable svelte/prefer-svelte-reactivity -- temporary Map/Date for data grouping, not reactive state */
4041 const displayedTimeline = $derived(() => {
4142 const entries = displayedEntries();
4243 if (entries.length === 0) return [];
@@ -78,6 +79,7 @@
7879
7980 return result.sort((a, b) => b.date.localeCompare(a.date));
8081 });
82+ /* eslint-enable svelte/prefer-svelte-reactivity */
8183
8284 const handleSelectEntry = (entry: HistoryEntry) => {
8385 navigateTo(entry.url);
@@ -37,6 +37,7 @@
37 });37 });
38 38
39 // Recompute timeline from filtered entries39 // Recompute timeline from filtered entries
40+ /* eslint-disable svelte/prefer-svelte-reactivity -- temporary Map/Date for data grouping, not reactive state */
40 const displayedTimeline = $derived(() => {41 const displayedTimeline = $derived(() => {
41 const entries = displayedEntries();42 const entries = displayedEntries();
42 if (entries.length === 0) return [];43 if (entries.length === 0) return [];
@@ -78,6 +79,7 @@
78 79
79 return result.sort((a, b) => b.date.localeCompare(a.date));80 return result.sort((a, b) => b.date.localeCompare(a.date));
80 });81 });
82+ /* eslint-enable svelte/prefer-svelte-reactivity */
81 83
82 const handleSelectEntry = (entry: HistoryEntry) => {84 const handleSelectEntry = (entry: HistoryEntry) => {
83 navigateTo(entry.url);85 navigateTo(entry.url);
M webui/src/pages/homepage/shell/CommandPalette.svelte +1 -1
@@ -165,7 +165,7 @@
165165
166166 // Reset selection when query changes
167167 $effect(() => {
168- query; // Track query
168+ void query; // Track query changes
169169 selectedIndex = 0;
170170 });
171171
@@ -165,7 +165,7 @@
165 165
166 // Reset selection when query changes166 // Reset selection when query changes
167 $effect(() => {167 $effect(() => {
168- query; // Track query168+ void query; // Track query changes
169 selectedIndex = 0;169 selectedIndex = 0;
170 });170 });
171 171
M webui/src/pages/homepage/shell/HomepageShell.svelte +6 -9
@@ -35,14 +35,12 @@
3535 };
3636
3737 function navigateWithViewTransition(url: string) {
38- const doc = document as any;
39-
40- if (typeof doc?.startViewTransition !== 'function') {
38+ if (typeof document.startViewTransition !== 'function') {
4139 window.location.href = url;
4240 return;
4341 }
4442
45- const transition = doc.startViewTransition(() => {
43+ const transition = document.startViewTransition(() => {
4644 document.documentElement.dataset.vt = 'leaving';
4745 });
4846
@@ -53,17 +51,15 @@
5351
5452 const toggleTheme = () => {
5553 const nextMode = themeMode === 'dark' ? 'light' : 'dark';
56- const manager = (window as any).__dumber_color_scheme_manager as
57- | { setUserPreference?: (theme: ThemeMode) => void }
58- | undefined;
54+ const manager = window.__dumber_color_scheme_manager;
5955
6056 if (manager?.setUserPreference) {
6157 manager.setUserPreference(nextMode);
6258 return;
6359 }
6460
65- if ((window as any).__dumber_setTheme) {
66- (window as any).__dumber_setTheme(nextMode);
61+ if (window.__dumber_setTheme) {
62+ window.__dumber_setTheme(nextMode);
6763 localStorage.setItem('dumber.theme', nextMode);
6864 return;
6965 }
@@ -109,6 +105,7 @@
109105 <svelte:head>
110106 <title>dumb://home</title>
111107 <meta name="description" content="Dumber Browser - Homepage" />
108+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
112109 {@html `<style>
113110 html, body { margin: 0; padding: 0; }
114111 html { background: var(--background, #0a0a0a); }
@@ -35,14 +35,12 @@
35 };35 };
36 36
37 function navigateWithViewTransition(url: string) {37 function navigateWithViewTransition(url: string) {
38- const doc = document as any;38+ if (typeof document.startViewTransition !== 'function') {
39-
40- if (typeof doc?.startViewTransition !== 'function') {
41 window.location.href = url;39 window.location.href = url;
42 return;40 return;
43 }41 }
44 42
45- const transition = doc.startViewTransition(() => {43+ const transition = document.startViewTransition(() => {
46 document.documentElement.dataset.vt = 'leaving';44 document.documentElement.dataset.vt = 'leaving';
47 });45 });
48 46
@@ -53,17 +51,15 @@
53 51
54 const toggleTheme = () => {52 const toggleTheme = () => {
55 const nextMode = themeMode === 'dark' ? 'light' : 'dark';53 const nextMode = themeMode === 'dark' ? 'light' : 'dark';
56- const manager = (window as any).__dumber_color_scheme_manager as54+ const manager = window.__dumber_color_scheme_manager;
57- | { setUserPreference?: (theme: ThemeMode) => void }
58- | undefined;
59 55
60 if (manager?.setUserPreference) {56 if (manager?.setUserPreference) {
61 manager.setUserPreference(nextMode);57 manager.setUserPreference(nextMode);
62 return;58 return;
63 }59 }
64 60
65- if ((window as any).__dumber_setTheme) {61+ if (window.__dumber_setTheme) {
66- (window as any).__dumber_setTheme(nextMode);62+ window.__dumber_setTheme(nextMode);
67 localStorage.setItem('dumber.theme', nextMode);63 localStorage.setItem('dumber.theme', nextMode);
68 return;64 return;
69 }65 }
@@ -109,6 +105,7 @@
109 <svelte:head>105 <svelte:head>
110 <title>dumb://home</title>106 <title>dumb://home</title>
111 <meta name="description" content="Dumber Browser - Homepage" />107 <meta name="description" content="Dumber Browser - Homepage" />
108+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
112 {@html `<style>109 {@html `<style>
113 html, body { margin: 0; padding: 0; }110 html, body { margin: 0; padding: 0; }
114 html { background: var(--background, #0a0a0a); }111 html { background: var(--background, #0a0a0a); }
M webui/src/pages/homepage/shell/KeyboardHints.svelte +4 -4
@@ -136,7 +136,7 @@
136136 {#each navHints as hint (hint.description)}
137137 <div class="hint-row">
138138 <div class="hint-keys">
139- {#each hint.keys as key, i}
139+ {#each hint.keys as key, i (key)}
140140 {#if i > 0}<span class="key-sep">/</span>{/if}
141141 <kbd>{key}</kbd>
142142 {/each}
@@ -157,7 +157,7 @@
157157 {#each panelSwitchHints as hint (hint.description)}
158158 <div class="hint-row">
159159 <div class="hint-keys">
160- {#each hint.keys as key, i}
160+ {#each hint.keys as key, i (key)}
161161 {#if i > 0}<span class="key-sep">/</span>{/if}
162162 <kbd>{key}</kbd>
163163 {/each}
@@ -179,7 +179,7 @@
179179 {#each panelHints as hint (hint.description)}
180180 <div class="hint-row">
181181 <div class="hint-keys">
182- {#each hint.keys as key, i}
182+ {#each hint.keys as key, i (key)}
183183 {#if i > 0}<span class="key-sep">/</span>{/if}
184184 <kbd>{key}</kbd>
185185 {/each}
@@ -201,7 +201,7 @@
201201 {#each quickAccessHints as hint (hint.description)}
202202 <div class="hint-row">
203203 <div class="hint-keys">
204- {#each hint.keys as key, i}
204+ {#each hint.keys as key, i (key)}
205205 {#if i > 0}<span class="key-sep">/</span>{/if}
206206 <kbd>{key}</kbd>
207207 {/each}
@@ -136,7 +136,7 @@
136 {#each navHints as hint (hint.description)}136 {#each navHints as hint (hint.description)}
137 <div class="hint-row">137 <div class="hint-row">
138 <div class="hint-keys">138 <div class="hint-keys">
139- {#each hint.keys as key, i}139+ {#each hint.keys as key, i (key)}
140 {#if i > 0}<span class="key-sep">/</span>{/if}140 {#if i > 0}<span class="key-sep">/</span>{/if}
141 <kbd>{key}</kbd>141 <kbd>{key}</kbd>
142 {/each}142 {/each}
@@ -157,7 +157,7 @@
157 {#each panelSwitchHints as hint (hint.description)}157 {#each panelSwitchHints as hint (hint.description)}
158 <div class="hint-row">158 <div class="hint-row">
159 <div class="hint-keys">159 <div class="hint-keys">
160- {#each hint.keys as key, i}160+ {#each hint.keys as key, i (key)}
161 {#if i > 0}<span class="key-sep">/</span>{/if}161 {#if i > 0}<span class="key-sep">/</span>{/if}
162 <kbd>{key}</kbd>162 <kbd>{key}</kbd>
163 {/each}163 {/each}
@@ -179,7 +179,7 @@
179 {#each panelHints as hint (hint.description)}179 {#each panelHints as hint (hint.description)}
180 <div class="hint-row">180 <div class="hint-row">
181 <div class="hint-keys">181 <div class="hint-keys">
182- {#each hint.keys as key, i}182+ {#each hint.keys as key, i (key)}
183 {#if i > 0}<span class="key-sep">/</span>{/if}183 {#if i > 0}<span class="key-sep">/</span>{/if}
184 <kbd>{key}</kbd>184 <kbd>{key}</kbd>
185 {/each}185 {/each}
@@ -201,7 +201,7 @@
201 {#each quickAccessHints as hint (hint.description)}201 {#each quickAccessHints as hint (hint.description)}
202 <div class="hint-row">202 <div class="hint-row">
203 <div class="hint-keys">203 <div class="hint-keys">
204- {#each hint.keys as key, i}204+ {#each hint.keys as key, i (key)}
205 {#if i > 0}<span class="key-sep">/</span>{/if}205 {#if i > 0}<span class="key-sep">/</span>{/if}
206 <kbd>{key}</kbd>206 <kbd>{key}</kbd>
207 {/each}207 {/each}
M webui/src/pages/homepage/shell/StatusBar.svelte +2 -1
@@ -38,7 +38,7 @@
3838 label: 'entries',
3939 filtered: false,
4040 };
41- case 'favorites':
41+ case 'favorites': {
4242 const filtered = homepageState.filteredFavorites;
4343 const total = homepageState.favorites.length;
4444 return {
@@ -46,6 +46,7 @@
4646 label: filtered.length === total ? 'items' : `of ${total}`,
4747 filtered: filtered.length !== total,
4848 };
49+ }
4950 case 'analytics':
5051 return {
5152 count: homepageState.domainStats.length,
@@ -38,7 +38,7 @@
38 label: 'entries',38 label: 'entries',
39 filtered: false,39 filtered: false,
40 };40 };
41- case 'favorites':41+ case 'favorites': {
42 const filtered = homepageState.filteredFavorites;42 const filtered = homepageState.filteredFavorites;
43 const total = homepageState.favorites.length;43 const total = homepageState.favorites.length;
44 return {44 return {
@@ -46,6 +46,7 @@
46 label: filtered.length === total ? 'items' : `of ${total}`,46 label: filtered.length === total ? 'items' : `of ${total}`,
47 filtered: filtered.length !== total,47 filtered: filtered.length !== total,
48 };48 };
49+ }
49 case 'analytics':50 case 'analytics':
50 return {51 return {
51 count: homepageState.domainStats.length,52 count: homepageState.domainStats.length,
M webui/src/pages/homepage/state.svelte.ts +2 -0
@@ -71,6 +71,7 @@ let pendingKeyPrefix = $state<string | null>(null);
7171 // HELPER FUNCTIONS
7272 // ═══════════════════════════════════════════════════════════════════════════════
7373
74+/* eslint-disable svelte/prefer-svelte-reactivity -- helper function uses temporary Map/Date, not reactive state */
7475 function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
7576 const groups = new Map<string, HistoryEntry[]>();
7677 const today = new Date();
@@ -109,6 +110,7 @@ function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
109110
110111 return result.sort((a, b) => b.date.localeCompare(a.date));
111112 }
113+/* eslint-enable svelte/prefer-svelte-reactivity */
112114
113115 // ═══════════════════════════════════════════════════════════════════════════════
114116 // EXPORTED STORE
@@ -71,6 +71,7 @@ let pendingKeyPrefix = $state<string | null>(null);
71 // HELPER FUNCTIONS71 // HELPER FUNCTIONS
72 // ═══════════════════════════════════════════════════════════════════════════════72 // ═══════════════════════════════════════════════════════════════════════════════
73 73
74+/* eslint-disable svelte/prefer-svelte-reactivity -- helper function uses temporary Map/Date, not reactive state */
74 function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {75 function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
75 const groups = new Map<string, HistoryEntry[]>();76 const groups = new Map<string, HistoryEntry[]>();
76 const today = new Date();77 const today = new Date();
@@ -109,6 +110,7 @@ function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
109 110
110 return result.sort((a, b) => b.date.localeCompare(a.date));111 return result.sort((a, b) => b.date.localeCompare(a.date));
111 }112 }
113+/* eslint-enable svelte/prefer-svelte-reactivity */
112 114
113 // ═══════════════════════════════════════════════════════════════════════════════115 // ═══════════════════════════════════════════════════════════════════════════════
114 // EXPORTED STORE116 // EXPORTED STORE

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