detached 7e934a8

feat(webui): add keybindings editor UI in config page

commit
7e934a87ae383f6eb00dc78cc42b305af7de5cc0
author
bnema · · 2026-01-11 14:21 UTC
committer
brice · · 2026-01-24 06:22 UTC
parent
5d469a0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +517 -0
M webui/src/pages/ConfigPage.svelte +6 -0
@@ -3,6 +3,7 @@
33 import { ModeWatcher } from "mode-watcher";
44 import ConfigShell from "./config/ConfigShell.svelte";
55 import ShortcutsTable from "./config/ShortcutsTable.svelte";
6+ import KeybindingsTab from "./config/KeybindingsTab.svelte";
67 import * as AlertDialog from "$lib/components/ui/alert-dialog";
78 import { Button } from "$lib/components/ui/button";
89 import { ColorPicker } from "$lib/components/ui/color-picker";
@@ -298,6 +299,7 @@
298299 <Tabs.List class="bg-muted/60">
299300 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
300301 <Tabs.Trigger value="search">Search</Tabs.Trigger>
302+ <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
301303 <Tabs.Trigger value="performance">Performance</Tabs.Trigger>
302304 </Tabs.List>
303305 </div>
@@ -424,6 +426,10 @@
424426 </Card.Root>
425427 </Tabs.Content>
426428
429+ <Tabs.Content value="keybindings">
430+ <KeybindingsTab />
431+ </Tabs.Content>
432+
427433 <Tabs.Content value="performance">
428434 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
429435 <Card.Header>
@@ -3,6 +3,7 @@
3 import { ModeWatcher } from "mode-watcher";3 import { ModeWatcher } from "mode-watcher";
4 import ConfigShell from "./config/ConfigShell.svelte";4 import ConfigShell from "./config/ConfigShell.svelte";
5 import ShortcutsTable from "./config/ShortcutsTable.svelte";5 import ShortcutsTable from "./config/ShortcutsTable.svelte";
6+ import KeybindingsTab from "./config/KeybindingsTab.svelte";
6 import * as AlertDialog from "$lib/components/ui/alert-dialog";7 import * as AlertDialog from "$lib/components/ui/alert-dialog";
7 import { Button } from "$lib/components/ui/button";8 import { Button } from "$lib/components/ui/button";
8 import { ColorPicker } from "$lib/components/ui/color-picker";9 import { ColorPicker } from "$lib/components/ui/color-picker";
@@ -298,6 +299,7 @@
298 <Tabs.List class="bg-muted/60">299 <Tabs.List class="bg-muted/60">
299 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>300 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
300 <Tabs.Trigger value="search">Search</Tabs.Trigger>301 <Tabs.Trigger value="search">Search</Tabs.Trigger>
302+ <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
301 <Tabs.Trigger value="performance">Performance</Tabs.Trigger>303 <Tabs.Trigger value="performance">Performance</Tabs.Trigger>
302 </Tabs.List>304 </Tabs.List>
303 </div>305 </div>
@@ -424,6 +426,10 @@
424 </Card.Root>426 </Card.Root>
425 </Tabs.Content>427 </Tabs.Content>
426 428
429+ <Tabs.Content value="keybindings">
430+ <KeybindingsTab />
431+ </Tabs.Content>
432+
427 <Tabs.Content value="performance">433 <Tabs.Content value="performance">
428 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">434 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
429 <Card.Header>435 <Card.Header>
A webui/src/pages/config/KeyCaptureModal.svelte +195 -0
@@ -0,0 +1,195 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import { Button } from "$lib/components/ui/button";
4+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
5+ import { Keyboard, X, Plus } from "@lucide/svelte";
6+
7+ type KeybindingEntry = {
8+ action: string;
9+ description: string;
10+ keys: string[];
11+ default_keys: string[];
12+ is_custom: boolean;
13+ };
14+
15+ interface Props {
16+ binding: KeybindingEntry;
17+ onSave: (keys: string[]) => void;
18+ onCancel: () => void;
19+ }
20+
21+ let { binding, onSave, onCancel }: Props = $props();
22+
23+ // Use $derived for initial keys to maintain reactivity if binding changes
24+ let newKeys = $state<string[]>([]);
25+ let isCapturing = $state(false);
26+ let capturedKey = $state<string | null>(null);
27+ let initialized = $state(false);
28+
29+ // Initialize keys from binding on mount
30+ $effect(() => {
31+ if (!initialized) {
32+ newKeys = [...binding.keys];
33+ initialized = true;
34+ }
35+ });
36+
37+ function handleKeyDown(e: KeyboardEvent) {
38+ if (!isCapturing) return;
39+ e.preventDefault();
40+ e.stopPropagation();
41+
42+ const parts: string[] = [];
43+ if (e.ctrlKey) parts.push("ctrl");
44+ if (e.altKey) parts.push("alt");
45+ if (e.shiftKey) parts.push("shift");
46+
47+ let key = e.key.toLowerCase();
48+ switch (key) {
49+ case "control":
50+ case "alt":
51+ case "shift":
52+ case "meta":
53+ return; // Modifier only, wait for actual key
54+ case "arrowleft":
55+ key = "arrowleft";
56+ break;
57+ case "arrowright":
58+ key = "arrowright";
59+ break;
60+ case "arrowup":
61+ key = "arrowup";
62+ break;
63+ case "arrowdown":
64+ key = "arrowdown";
65+ break;
66+ case "escape":
67+ isCapturing = false;
68+ capturedKey = null;
69+ return;
70+ case " ":
71+ key = "space";
72+ break;
73+ case "enter":
74+ key = "enter";
75+ break;
76+ case "tab":
77+ key = "tab";
78+ break;
79+ case "backspace":
80+ key = "backspace";
81+ break;
82+ }
83+
84+ parts.push(key);
85+ capturedKey = parts.join("+");
86+ isCapturing = false;
87+ }
88+
89+ function addCapturedKey() {
90+ if (capturedKey && !newKeys.includes(capturedKey)) {
91+ newKeys = [...newKeys, capturedKey];
92+ }
93+ capturedKey = null;
94+ }
95+
96+ function removeKey(index: number) {
97+ newKeys = newKeys.filter((_, i) => i !== index);
98+ }
99+
100+ function formatKey(key: string): string {
101+ return key
102+ .replace(/arrowleft/gi, "Left")
103+ .replace(/arrowright/gi, "Right")
104+ .replace(/arrowup/gi, "Up")
105+ .replace(/arrowdown/gi, "Down")
106+ .replace(/\+/g, " + ")
107+ .replace(/ctrl/gi, "Ctrl")
108+ .replace(/alt/gi, "Alt")
109+ .replace(/shift/gi, "Shift");
110+ }
111+
112+ onMount(() => {
113+ const handler = (e: KeyboardEvent) => handleKeyDown(e);
114+ window.addEventListener("keydown", handler, true);
115+ return () => window.removeEventListener("keydown", handler, true);
116+ });
117+</script>
118+
119+<AlertDialog.Root open={true} onOpenChange={(open) => !open && onCancel()}>
120+ <AlertDialog.Content class="max-w-md">
121+ <AlertDialog.Header>
122+ <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
123+ <AlertDialog.Description>
124+ Configure keybindings for <strong>{binding.action}</strong>
125+ </AlertDialog.Description>
126+ </AlertDialog.Header>
127+
128+ <div class="space-y-4 py-4">
129+ <!-- Current bindings -->
130+ <div class="space-y-2">
131+ <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
132+ Current Bindings
133+ </div>
134+ <div class="flex flex-wrap gap-2">
135+ {#each newKeys as key, i}
136+ <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
137+ <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
138+ <button
139+ type="button"
140+ onclick={() => removeKey(i)}
141+ class="text-muted-foreground hover:text-foreground"
142+ >
143+ <X class="size-3" />
144+ </button>
145+ </div>
146+ {/each}
147+ {#if newKeys.length === 0}
148+ <span class="text-sm text-muted-foreground">(no bindings)</span>
149+ {/if}
150+ </div>
151+ </div>
152+
153+ <!-- Capture area -->
154+ <div class="space-y-2">
155+ <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
156+ Add New Binding
157+ </div>
158+ <div class="flex gap-2">
159+ <button
160+ type="button"
161+ onclick={() => { isCapturing = true; capturedKey = null; }}
162+ class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}"
163+ >
164+ <Keyboard class="size-5" />
165+ {#if isCapturing}
166+ Press a key combination...
167+ {:else if capturedKey}
168+ <kbd class="font-mono">{formatKey(capturedKey)}</kbd>
169+ {:else}
170+ Click to capture key
171+ {/if}
172+ </button>
173+ {#if capturedKey}
174+ <Button variant="outline" onclick={addCapturedKey}>
175+ <Plus class="mr-1 size-4" />
176+ Add
177+ </Button>
178+ {/if}
179+ </div>
180+ </div>
181+
182+ <!-- Default hint -->
183+ {#if binding.default_keys.length > 0}
184+ <div class="text-xs text-muted-foreground">
185+ Default: {binding.default_keys.map(formatKey).join(", ")}
186+ </div>
187+ {/if}
188+ </div>
189+
190+ <AlertDialog.Footer>
191+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
192+ <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action>
193+ </AlertDialog.Footer>
194+ </AlertDialog.Content>
195+</AlertDialog.Root>
@@ -0,0 +1,195 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import { Button } from "$lib/components/ui/button";
4+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
5+ import { Keyboard, X, Plus } from "@lucide/svelte";
6+
7+ type KeybindingEntry = {
8+ action: string;
9+ description: string;
10+ keys: string[];
11+ default_keys: string[];
12+ is_custom: boolean;
13+ };
14+
15+ interface Props {
16+ binding: KeybindingEntry;
17+ onSave: (keys: string[]) => void;
18+ onCancel: () => void;
19+ }
20+
21+ let { binding, onSave, onCancel }: Props = $props();
22+
23+ // Use $derived for initial keys to maintain reactivity if binding changes
24+ let newKeys = $state<string[]>([]);
25+ let isCapturing = $state(false);
26+ let capturedKey = $state<string | null>(null);
27+ let initialized = $state(false);
28+
29+ // Initialize keys from binding on mount
30+ $effect(() => {
31+ if (!initialized) {
32+ newKeys = [...binding.keys];
33+ initialized = true;
34+ }
35+ });
36+
37+ function handleKeyDown(e: KeyboardEvent) {
38+ if (!isCapturing) return;
39+ e.preventDefault();
40+ e.stopPropagation();
41+
42+ const parts: string[] = [];
43+ if (e.ctrlKey) parts.push("ctrl");
44+ if (e.altKey) parts.push("alt");
45+ if (e.shiftKey) parts.push("shift");
46+
47+ let key = e.key.toLowerCase();
48+ switch (key) {
49+ case "control":
50+ case "alt":
51+ case "shift":
52+ case "meta":
53+ return; // Modifier only, wait for actual key
54+ case "arrowleft":
55+ key = "arrowleft";
56+ break;
57+ case "arrowright":
58+ key = "arrowright";
59+ break;
60+ case "arrowup":
61+ key = "arrowup";
62+ break;
63+ case "arrowdown":
64+ key = "arrowdown";
65+ break;
66+ case "escape":
67+ isCapturing = false;
68+ capturedKey = null;
69+ return;
70+ case " ":
71+ key = "space";
72+ break;
73+ case "enter":
74+ key = "enter";
75+ break;
76+ case "tab":
77+ key = "tab";
78+ break;
79+ case "backspace":
80+ key = "backspace";
81+ break;
82+ }
83+
84+ parts.push(key);
85+ capturedKey = parts.join("+");
86+ isCapturing = false;
87+ }
88+
89+ function addCapturedKey() {
90+ if (capturedKey && !newKeys.includes(capturedKey)) {
91+ newKeys = [...newKeys, capturedKey];
92+ }
93+ capturedKey = null;
94+ }
95+
96+ function removeKey(index: number) {
97+ newKeys = newKeys.filter((_, i) => i !== index);
98+ }
99+
100+ function formatKey(key: string): string {
101+ return key
102+ .replace(/arrowleft/gi, "Left")
103+ .replace(/arrowright/gi, "Right")
104+ .replace(/arrowup/gi, "Up")
105+ .replace(/arrowdown/gi, "Down")
106+ .replace(/\+/g, " + ")
107+ .replace(/ctrl/gi, "Ctrl")
108+ .replace(/alt/gi, "Alt")
109+ .replace(/shift/gi, "Shift");
110+ }
111+
112+ onMount(() => {
113+ const handler = (e: KeyboardEvent) => handleKeyDown(e);
114+ window.addEventListener("keydown", handler, true);
115+ return () => window.removeEventListener("keydown", handler, true);
116+ });
117+</script>
118+
119+<AlertDialog.Root open={true} onOpenChange={(open) => !open && onCancel()}>
120+ <AlertDialog.Content class="max-w-md">
121+ <AlertDialog.Header>
122+ <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
123+ <AlertDialog.Description>
124+ Configure keybindings for <strong>{binding.action}</strong>
125+ </AlertDialog.Description>
126+ </AlertDialog.Header>
127+
128+ <div class="space-y-4 py-4">
129+ <!-- Current bindings -->
130+ <div class="space-y-2">
131+ <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
132+ Current Bindings
133+ </div>
134+ <div class="flex flex-wrap gap-2">
135+ {#each newKeys as key, i}
136+ <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
137+ <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
138+ <button
139+ type="button"
140+ onclick={() => removeKey(i)}
141+ class="text-muted-foreground hover:text-foreground"
142+ >
143+ <X class="size-3" />
144+ </button>
145+ </div>
146+ {/each}
147+ {#if newKeys.length === 0}
148+ <span class="text-sm text-muted-foreground">(no bindings)</span>
149+ {/if}
150+ </div>
151+ </div>
152+
153+ <!-- Capture area -->
154+ <div class="space-y-2">
155+ <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
156+ Add New Binding
157+ </div>
158+ <div class="flex gap-2">
159+ <button
160+ type="button"
161+ onclick={() => { isCapturing = true; capturedKey = null; }}
162+ class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}"
163+ >
164+ <Keyboard class="size-5" />
165+ {#if isCapturing}
166+ Press a key combination...
167+ {:else if capturedKey}
168+ <kbd class="font-mono">{formatKey(capturedKey)}</kbd>
169+ {:else}
170+ Click to capture key
171+ {/if}
172+ </button>
173+ {#if capturedKey}
174+ <Button variant="outline" onclick={addCapturedKey}>
175+ <Plus class="mr-1 size-4" />
176+ Add
177+ </Button>
178+ {/if}
179+ </div>
180+ </div>
181+
182+ <!-- Default hint -->
183+ {#if binding.default_keys.length > 0}
184+ <div class="text-xs text-muted-foreground">
185+ Default: {binding.default_keys.map(formatKey).join(", ")}
186+ </div>
187+ {/if}
188+ </div>
189+
190+ <AlertDialog.Footer>
191+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
192+ <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action>
193+ </AlertDialog.Footer>
194+ </AlertDialog.Content>
195+</AlertDialog.Root>
A webui/src/pages/config/KeybindingGroup.svelte +106 -0
@@ -0,0 +1,106 @@
1+<script lang="ts">
2+ import { Button } from "$lib/components/ui/button";
3+ import * as Table from "$lib/components/ui/table";
4+ import { RotateCcw, Edit3 } from "@lucide/svelte";
5+
6+ type KeybindingEntry = {
7+ action: string;
8+ description: string;
9+ keys: string[];
10+ default_keys: string[];
11+ is_custom: boolean;
12+ };
13+
14+ type KeybindingGroupData = {
15+ mode: string;
16+ display_name: string;
17+ bindings: KeybindingEntry[];
18+ activation?: string;
19+ };
20+
21+ interface Props {
22+ group: KeybindingGroupData;
23+ onEdit: (action: string, entry: KeybindingEntry) => void;
24+ onReset: (action: string) => void;
25+ }
26+
27+ let { group, onEdit, onReset }: Props = $props();
28+
29+ function formatKey(key: string): string {
30+ return key
31+ .replace(/arrowleft/gi, "Left")
32+ .replace(/arrowright/gi, "Right")
33+ .replace(/arrowup/gi, "Up")
34+ .replace(/arrowdown/gi, "Down")
35+ .replace(/\+/g, " + ")
36+ .replace(/ctrl/gi, "Ctrl")
37+ .replace(/alt/gi, "Alt")
38+ .replace(/shift/gi, "Shift");
39+ }
40+</script>
41+
42+<div class="space-y-3">
43+ <div class="flex items-center gap-2">
44+ <h3 class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
45+ {group.display_name}
46+ </h3>
47+ {#if group.activation}
48+ <span class="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground">
49+ {formatKey(group.activation)}
50+ </span>
51+ {/if}
52+ </div>
53+
54+ <Table.Root class="rounded-md border border-border">
55+ <Table.Header>
56+ <Table.Row>
57+ <Table.Head class="w-[180px]">Action</Table.Head>
58+ <Table.Head>Description</Table.Head>
59+ <Table.Head class="w-[200px]">Keybinding</Table.Head>
60+ <Table.Head class="w-[100px] text-right">Actions</Table.Head>
61+ </Table.Row>
62+ </Table.Header>
63+ <Table.Body>
64+ {#each group.bindings as binding (binding.action)}
65+ <Table.Row class={binding.is_custom ? "bg-accent/5" : ""}>
66+ <Table.Cell class="font-mono text-sm">{binding.action}</Table.Cell>
67+ <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell>
68+ <Table.Cell>
69+ <div class="flex flex-wrap gap-1">
70+ {#each binding.keys as key}
71+ <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
72+ {formatKey(key)}
73+ </kbd>
74+ {/each}
75+ {#if binding.keys.length === 0}
76+ <span class="text-xs text-muted-foreground">(unbound)</span>
77+ {/if}
78+ </div>
79+ </Table.Cell>
80+ <Table.Cell class="text-right">
81+ <div class="flex justify-end gap-1">
82+ <Button
83+ variant="ghost"
84+ size="icon"
85+ onclick={() => onEdit(binding.action, binding)}
86+ aria-label="Edit binding"
87+ >
88+ <Edit3 class="size-4" />
89+ </Button>
90+ {#if binding.is_custom}
91+ <Button
92+ variant="ghost"
93+ size="icon"
94+ onclick={() => onReset(binding.action)}
95+ aria-label="Reset to default"
96+ >
97+ <RotateCcw class="size-4" />
98+ </Button>
99+ {/if}
100+ </div>
101+ </Table.Cell>
102+ </Table.Row>
103+ {/each}
104+ </Table.Body>
105+ </Table.Root>
106+</div>
@@ -0,0 +1,106 @@
1+<script lang="ts">
2+ import { Button } from "$lib/components/ui/button";
3+ import * as Table from "$lib/components/ui/table";
4+ import { RotateCcw, Edit3 } from "@lucide/svelte";
5+
6+ type KeybindingEntry = {
7+ action: string;
8+ description: string;
9+ keys: string[];
10+ default_keys: string[];
11+ is_custom: boolean;
12+ };
13+
14+ type KeybindingGroupData = {
15+ mode: string;
16+ display_name: string;
17+ bindings: KeybindingEntry[];
18+ activation?: string;
19+ };
20+
21+ interface Props {
22+ group: KeybindingGroupData;
23+ onEdit: (action: string, entry: KeybindingEntry) => void;
24+ onReset: (action: string) => void;
25+ }
26+
27+ let { group, onEdit, onReset }: Props = $props();
28+
29+ function formatKey(key: string): string {
30+ return key
31+ .replace(/arrowleft/gi, "Left")
32+ .replace(/arrowright/gi, "Right")
33+ .replace(/arrowup/gi, "Up")
34+ .replace(/arrowdown/gi, "Down")
35+ .replace(/\+/g, " + ")
36+ .replace(/ctrl/gi, "Ctrl")
37+ .replace(/alt/gi, "Alt")
38+ .replace(/shift/gi, "Shift");
39+ }
40+</script>
41+
42+<div class="space-y-3">
43+ <div class="flex items-center gap-2">
44+ <h3 class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
45+ {group.display_name}
46+ </h3>
47+ {#if group.activation}
48+ <span class="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground">
49+ {formatKey(group.activation)}
50+ </span>
51+ {/if}
52+ </div>
53+
54+ <Table.Root class="rounded-md border border-border">
55+ <Table.Header>
56+ <Table.Row>
57+ <Table.Head class="w-[180px]">Action</Table.Head>
58+ <Table.Head>Description</Table.Head>
59+ <Table.Head class="w-[200px]">Keybinding</Table.Head>
60+ <Table.Head class="w-[100px] text-right">Actions</Table.Head>
61+ </Table.Row>
62+ </Table.Header>
63+ <Table.Body>
64+ {#each group.bindings as binding (binding.action)}
65+ <Table.Row class={binding.is_custom ? "bg-accent/5" : ""}>
66+ <Table.Cell class="font-mono text-sm">{binding.action}</Table.Cell>
67+ <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell>
68+ <Table.Cell>
69+ <div class="flex flex-wrap gap-1">
70+ {#each binding.keys as key}
71+ <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
72+ {formatKey(key)}
73+ </kbd>
74+ {/each}
75+ {#if binding.keys.length === 0}
76+ <span class="text-xs text-muted-foreground">(unbound)</span>
77+ {/if}
78+ </div>
79+ </Table.Cell>
80+ <Table.Cell class="text-right">
81+ <div class="flex justify-end gap-1">
82+ <Button
83+ variant="ghost"
84+ size="icon"
85+ onclick={() => onEdit(binding.action, binding)}
86+ aria-label="Edit binding"
87+ >
88+ <Edit3 class="size-4" />
89+ </Button>
90+ {#if binding.is_custom}
91+ <Button
92+ variant="ghost"
93+ size="icon"
94+ onclick={() => onReset(binding.action)}
95+ aria-label="Reset to default"
96+ >
97+ <RotateCcw class="size-4" />
98+ </Button>
99+ {/if}
100+ </div>
101+ </Table.Cell>
102+ </Table.Row>
103+ {/each}
104+ </Table.Body>
105+ </Table.Root>
106+</div>
A webui/src/pages/config/KeybindingsTab.svelte +210 -0
@@ -0,0 +1,210 @@
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 } from "@lucide/svelte";
8+ import KeybindingGroup from "./KeybindingGroup.svelte";
9+ import KeyCaptureModal from "./KeyCaptureModal.svelte";
10+
11+ type KeybindingEntry = {
12+ action: string;
13+ description: string;
14+ keys: string[];
15+ default_keys: string[];
16+ is_custom: boolean;
17+ };
18+
19+ type KeybindingGroupData = {
20+ mode: string;
21+ display_name: string;
22+ bindings: KeybindingEntry[];
23+ activation?: string;
24+ };
25+
26+ type KeybindingsConfig = {
27+ groups: KeybindingGroupData[];
28+ };
29+
30+ let loading = $state(true);
31+ let error = $state<string | null>(null);
32+ let keybindings = $state<KeybindingsConfig | null>(null);
33+ let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
34+ 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+ }
44+
45+ async function loadKeybindings() {
46+ loading = true;
47+ error = null;
48+
49+ const bridge = getWebKitBridge();
50+ if (!bridge) {
51+ error = "WebKit bridge not available";
52+ loading = false;
53+ return;
54+ }
55+
56+ (window as any).__dumber_keybindings_loaded = (data: KeybindingsConfig) => {
57+ keybindings = data;
58+ loading = false;
59+ };
60+ (window as any).__dumber_keybindings_error = (msg: string) => {
61+ error = typeof msg === "string" ? msg : "Failed to load keybindings";
62+ loading = false;
63+ };
64+
65+ bridge.postMessage({
66+ type: "get_keybindings",
67+ webviewId: getWebViewId(),
68+ payload: {},
69+ });
70+ }
71+
72+ function startEdit(mode: string, action: string, entry: KeybindingEntry) {
73+ editingBinding = { mode, action, entry };
74+ }
75+
76+ async function saveBinding(keys: string[]) {
77+ if (!editingBinding) return;
78+
79+ const bridge = getWebKitBridge();
80+ if (!bridge) return;
81+
82+ (window as any).__dumber_keybinding_set = () => {
83+ loadKeybindings();
84+ editingBinding = null;
85+ };
86+ (window as any).__dumber_keybinding_error = (msg: string) => {
87+ error = typeof msg === "string" ? msg : "Failed to save keybinding";
88+ };
89+
90+ bridge.postMessage({
91+ type: "set_keybinding",
92+ webviewId: getWebViewId(),
93+ payload: {
94+ mode: editingBinding.mode,
95+ action: editingBinding.action,
96+ keys,
97+ },
98+ });
99+ }
100+
101+ async function resetBinding(mode: string, action: string) {
102+ const bridge = getWebKitBridge();
103+ if (!bridge) return;
104+
105+ (window as any).__dumber_keybinding_reset = () => {
106+ loadKeybindings();
107+ };
108+ (window as any).__dumber_keybinding_error = (msg: string) => {
109+ error = typeof msg === "string" ? msg : "Failed to reset keybinding";
110+ };
111+
112+ bridge.postMessage({
113+ type: "reset_keybinding",
114+ webviewId: getWebViewId(),
115+ payload: { mode, action },
116+ });
117+ }
118+
119+ async function resetAllBindings() {
120+ const bridge = getWebKitBridge();
121+ if (!bridge) return;
122+
123+ (window as any).__dumber_keybindings_reset_all = () => {
124+ loadKeybindings();
125+ resetAllDialogOpen = false;
126+ };
127+ (window as any).__dumber_keybinding_error = (msg: string) => {
128+ error = typeof msg === "string" ? msg : "Failed to reset keybindings";
129+ };
130+
131+ bridge.postMessage({
132+ type: "reset_all_keybindings",
133+ webviewId: getWebViewId(),
134+ payload: {},
135+ });
136+ }
137+
138+ onMount(() => {
139+ loadKeybindings();
140+ });
141+</script>
142+
143+<Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
144+ <Card.Header>
145+ <div class="flex items-center justify-between">
146+ <div>
147+ <Card.Title>Keybindings</Card.Title>
148+ <Card.Description>
149+ Customize keyboard shortcuts for all modes.
150+ </Card.Description>
151+ </div>
152+ <div class="flex gap-2">
153+ <Button variant="outline" size="sm" onclick={loadKeybindings}>
154+ <RefreshCw class="mr-1 size-4" />
155+ Refresh
156+ </Button>
157+ <AlertDialog.Root bind:open={resetAllDialogOpen}>
158+ <AlertDialog.Trigger>
159+ {#snippet child({ props })}
160+ <Button variant="outline" size="sm" {...props}>
161+ <RotateCcw class="mr-1 size-4" />
162+ Reset All
163+ </Button>
164+ {/snippet}
165+ </AlertDialog.Trigger>
166+ <AlertDialog.Content>
167+ <AlertDialog.Header>
168+ <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
169+ <AlertDialog.Description>
170+ This will restore all keybindings to their default values. This action cannot be undone.
171+ </AlertDialog.Description>
172+ </AlertDialog.Header>
173+ <AlertDialog.Footer>
174+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
175+ <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>
176+ </AlertDialog.Footer>
177+ </AlertDialog.Content>
178+ </AlertDialog.Root>
179+ </div>
180+ </div>
181+ </Card.Header>
182+ <Card.Content class="space-y-6">
183+ {#if loading}
184+ <div class="flex items-center justify-center py-8">
185+ <Spinner class="size-6" />
186+ </div>
187+ {:else if error}
188+ <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
189+ <AlertTriangle class="size-4" />
190+ {error}
191+ </div>
192+ {:else if keybindings}
193+ {#each keybindings.groups as group (group.mode)}
194+ <KeybindingGroup
195+ {group}
196+ onEdit={(action, entry) => startEdit(group.mode, action, entry)}
197+ onReset={(action) => resetBinding(group.mode, action)}
198+ />
199+ {/each}
200+ {/if}
201+ </Card.Content>
202+</Card.Root>
203+
204+{#if editingBinding}
205+ <KeyCaptureModal
206+ binding={editingBinding.entry}
207+ onSave={saveBinding}
208+ onCancel={() => (editingBinding = null)}
209+ />
210+{/if}
@@ -0,0 +1,210 @@
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 } from "@lucide/svelte";
8+ import KeybindingGroup from "./KeybindingGroup.svelte";
9+ import KeyCaptureModal from "./KeyCaptureModal.svelte";
10+
11+ type KeybindingEntry = {
12+ action: string;
13+ description: string;
14+ keys: string[];
15+ default_keys: string[];
16+ is_custom: boolean;
17+ };
18+
19+ type KeybindingGroupData = {
20+ mode: string;
21+ display_name: string;
22+ bindings: KeybindingEntry[];
23+ activation?: string;
24+ };
25+
26+ type KeybindingsConfig = {
27+ groups: KeybindingGroupData[];
28+ };
29+
30+ let loading = $state(true);
31+ let error = $state<string | null>(null);
32+ let keybindings = $state<KeybindingsConfig | null>(null);
33+ let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
34+ 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+ }
44+
45+ async function loadKeybindings() {
46+ loading = true;
47+ error = null;
48+
49+ const bridge = getWebKitBridge();
50+ if (!bridge) {
51+ error = "WebKit bridge not available";
52+ loading = false;
53+ return;
54+ }
55+
56+ (window as any).__dumber_keybindings_loaded = (data: KeybindingsConfig) => {
57+ keybindings = data;
58+ loading = false;
59+ };
60+ (window as any).__dumber_keybindings_error = (msg: string) => {
61+ error = typeof msg === "string" ? msg : "Failed to load keybindings";
62+ loading = false;
63+ };
64+
65+ bridge.postMessage({
66+ type: "get_keybindings",
67+ webviewId: getWebViewId(),
68+ payload: {},
69+ });
70+ }
71+
72+ function startEdit(mode: string, action: string, entry: KeybindingEntry) {
73+ editingBinding = { mode, action, entry };
74+ }
75+
76+ async function saveBinding(keys: string[]) {
77+ if (!editingBinding) return;
78+
79+ const bridge = getWebKitBridge();
80+ if (!bridge) return;
81+
82+ (window as any).__dumber_keybinding_set = () => {
83+ loadKeybindings();
84+ editingBinding = null;
85+ };
86+ (window as any).__dumber_keybinding_error = (msg: string) => {
87+ error = typeof msg === "string" ? msg : "Failed to save keybinding";
88+ };
89+
90+ bridge.postMessage({
91+ type: "set_keybinding",
92+ webviewId: getWebViewId(),
93+ payload: {
94+ mode: editingBinding.mode,
95+ action: editingBinding.action,
96+ keys,
97+ },
98+ });
99+ }
100+
101+ async function resetBinding(mode: string, action: string) {
102+ const bridge = getWebKitBridge();
103+ if (!bridge) return;
104+
105+ (window as any).__dumber_keybinding_reset = () => {
106+ loadKeybindings();
107+ };
108+ (window as any).__dumber_keybinding_error = (msg: string) => {
109+ error = typeof msg === "string" ? msg : "Failed to reset keybinding";
110+ };
111+
112+ bridge.postMessage({
113+ type: "reset_keybinding",
114+ webviewId: getWebViewId(),
115+ payload: { mode, action },
116+ });
117+ }
118+
119+ async function resetAllBindings() {
120+ const bridge = getWebKitBridge();
121+ if (!bridge) return;
122+
123+ (window as any).__dumber_keybindings_reset_all = () => {
124+ loadKeybindings();
125+ resetAllDialogOpen = false;
126+ };
127+ (window as any).__dumber_keybinding_error = (msg: string) => {
128+ error = typeof msg === "string" ? msg : "Failed to reset keybindings";
129+ };
130+
131+ bridge.postMessage({
132+ type: "reset_all_keybindings",
133+ webviewId: getWebViewId(),
134+ payload: {},
135+ });
136+ }
137+
138+ onMount(() => {
139+ loadKeybindings();
140+ });
141+</script>
142+
143+<Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
144+ <Card.Header>
145+ <div class="flex items-center justify-between">
146+ <div>
147+ <Card.Title>Keybindings</Card.Title>
148+ <Card.Description>
149+ Customize keyboard shortcuts for all modes.
150+ </Card.Description>
151+ </div>
152+ <div class="flex gap-2">
153+ <Button variant="outline" size="sm" onclick={loadKeybindings}>
154+ <RefreshCw class="mr-1 size-4" />
155+ Refresh
156+ </Button>
157+ <AlertDialog.Root bind:open={resetAllDialogOpen}>
158+ <AlertDialog.Trigger>
159+ {#snippet child({ props })}
160+ <Button variant="outline" size="sm" {...props}>
161+ <RotateCcw class="mr-1 size-4" />
162+ Reset All
163+ </Button>
164+ {/snippet}
165+ </AlertDialog.Trigger>
166+ <AlertDialog.Content>
167+ <AlertDialog.Header>
168+ <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
169+ <AlertDialog.Description>
170+ This will restore all keybindings to their default values. This action cannot be undone.
171+ </AlertDialog.Description>
172+ </AlertDialog.Header>
173+ <AlertDialog.Footer>
174+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
175+ <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>
176+ </AlertDialog.Footer>
177+ </AlertDialog.Content>
178+ </AlertDialog.Root>
179+ </div>
180+ </div>
181+ </Card.Header>
182+ <Card.Content class="space-y-6">
183+ {#if loading}
184+ <div class="flex items-center justify-center py-8">
185+ <Spinner class="size-6" />
186+ </div>
187+ {:else if error}
188+ <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
189+ <AlertTriangle class="size-4" />
190+ {error}
191+ </div>
192+ {:else if keybindings}
193+ {#each keybindings.groups as group (group.mode)}
194+ <KeybindingGroup
195+ {group}
196+ onEdit={(action, entry) => startEdit(group.mode, action, entry)}
197+ onReset={(action) => resetBinding(group.mode, action)}
198+ />
199+ {/each}
200+ {/if}
201+ </Card.Content>
202+</Card.Root>
203+
204+{#if editingBinding}
205+ <KeyCaptureModal
206+ binding={editingBinding.entry}
207+ onSave={saveBinding}
208+ onCancel={() => (editingBinding = null)}
209+ />
210+{/if}

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