detached 7a7ce57

feat(webui): add shortcuts management tab to config page with crud operations

commit
7a7ce5743b66888cb04a13d56a3eb0ee8e95525b
author
bnema · · 2025-12-21 13:53 UTC
parent
42bc673
signature
unsigned
1 file · +233 -286
M webui/src/pages/ConfigPage.svelte +233 -286
@@ -1,6 +1,13 @@
11 <script lang="ts">
22 import { onMount } from "svelte";
33 import ConfigShell from "./config/ConfigShell.svelte";
4+ import ShortcutsTable from "./config/ShortcutsTable.svelte";
5+ import { Button } from "$lib/components/ui/button";
6+ import { Input } from "$lib/components/ui/input";
7+ import { Label } from "$lib/components/ui/label";
8+ import { Spinner } from "$lib/components/ui/spinner";
9+ import * as Card from "$lib/components/ui/card";
10+ import * as Tabs from "$lib/components/ui/tabs";
411
512 type ColorPalette = {
613 background: string;
@@ -12,6 +19,11 @@
1219 border: string;
1320 };
1421
22+ type SearchShortcut = {
23+ url: string;
24+ description: string;
25+ };
26+
1527 type ConfigDTO = {
1628 appearance: {
1729 sans_font: string;
@@ -24,6 +36,7 @@
2436 };
2537 default_ui_scale: number;
2638 default_search_engine: string;
39+ search_shortcuts: Record<string, SearchShortcut>;
2740 };
2841
2942 const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
@@ -40,30 +53,39 @@
4053 return /^#[0-9a-fA-F]{6}$/.test(value);
4154 }
4255
43-
44- let config = $state<ConfigDTO | null>(null);
56+ // UI state
4557 let loading = $state(true);
46- let error = $state<string | null>(null);
58+ let loadError = $state<string | null>(null);
59+ let config = $state<ConfigDTO | null>(null);
4760 let saving = $state(false);
4861 let saveSuccess = $state(false);
62+ let saveError = $state<string | null>(null);
4963 let themeEvents = $state(0);
64+ let activeTab = $state("appearance");
5065
51- async function fetchConfig() {
66+ // Load config from API
67+ async function loadConfig() {
68+ loading = true;
69+ loadError = null;
5270 try {
5371 const response = await fetch("/api/config");
5472 if (!response.ok) throw new Error("Failed to fetch config");
5573 config = (await response.json()) as ConfigDTO;
5674 } catch (e: any) {
57- error = e.message;
75+ loadError = e.message;
76+ console.error("[config] load failed", e);
5877 } finally {
5978 loading = false;
6079 }
6180 }
6281
82+ // Reload config (used after save to get normalized values)
83+ function reloadConfig() {
84+ loadConfig();
85+ }
86+
6387 async function resetToDefaults() {
64- error = null;
6588 saveSuccess = false;
66-
6789 const confirmed = window.confirm(
6890 "Reset this page to default settings? (You still need to click Save to persist.)",
6991 );
@@ -74,7 +96,7 @@
7496 if (!response.ok) throw new Error("Failed to fetch default config");
7597 config = (await response.json()) as ConfigDTO;
7698 } catch (e: any) {
77- error = e.message;
99+ console.error("[config] reset defaults failed", e);
78100 }
79101 }
80102
@@ -93,7 +115,7 @@
93115 async function saveConfig() {
94116 saving = true;
95117 saveSuccess = false;
96- error = null;
118+ saveError = null;
97119
98120 try {
99121 const bridge = getWebKitBridge();
@@ -118,7 +140,7 @@
118140 let timeout: number | null = window.setTimeout(() => {
119141 if (saving) {
120142 console.warn("[config] save timed out (no callback)");
121- error = "Save timed out (no response from native handler)";
143+ saveError = "Save timed out (no response from native handler)";
122144 saving = false;
123145 }
124146 }, 8000);
@@ -136,8 +158,8 @@
136158 saveSuccess = true;
137159 saving = false;
138160
139- // Refresh config state from backend (in case watcher normalized values)
140- fetchConfig();
161+ // Refresh config from backend (in case watcher normalized values)
162+ reloadConfig();
141163
142164 setTimeout(() => {
143165 saveSuccess = false;
@@ -146,7 +168,7 @@
146168 (window as any).__dumber_config_error = (msg: unknown) => {
147169 clearSaveTimeout();
148170 console.error("[config] save error", msg);
149- error = typeof msg === "string" ? msg : "Failed to save config";
171+ saveError = typeof msg === "string" ? msg : "Failed to save config";
150172 saving = false;
151173 };
152174
@@ -165,11 +187,16 @@
165187 }
166188 } catch (e: any) {
167189 console.error("[config] save exception", e);
168- error = e.message;
190+ saveError = e.message;
169191 saving = false;
170192 }
171193 }
172194
195+ function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
196+ if (!config) return;
197+ config.search_shortcuts = nextShortcuts;
198+ }
199+
173200 onMount(() => {
174201 console.debug("[config] mount", {
175202 webviewId: (window as any).__dumber_webview_id,
@@ -180,300 +207,220 @@
180207 themeEvents += 1;
181208 console.debug("[config] theme changed", (e as CustomEvent).detail);
182209 };
183- window.addEventListener('dumber:theme-changed', onThemeChanged);
210+ window.addEventListener("dumber:theme-changed", onThemeChanged);
184211
185- fetchConfig();
212+ // Load config on mount
213+ loadConfig();
186214
187215 return () => {
188- window.removeEventListener('dumber:theme-changed', onThemeChanged);
216+ window.removeEventListener("dumber:theme-changed", onThemeChanged);
189217 };
190218 });
191219 </script>
192220
193221 <ConfigShell>
194- <div class="config-content">
222+ <div class="flex w-full flex-col gap-6 px-6 py-8">
195223 {#if loading}
196- <div class="loading">LOADING…</div>
197- {:else if error}
198- <div class="error">
199- <strong>ERROR</strong>
200- <span>{error}</span>
224+ <!-- Loading state with spinner -->
225+ <div class="flex flex-col items-center justify-center gap-4 py-16">
226+ <Spinner class="size-8" />
227+ <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground">
228+ Loading configuration…
229+ </div>
201230 </div>
202- {/if}
203-
204- {#if config}
205- <div class="stack">
206- <!-- Appearance Section -->
207- <section class="border border-border bg-card p-6 text-card-foreground">
208- <h2 class="mb-6 text-xl font-semibold">Appearance</h2>
209-
210- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
211- <div class="space-y-2">
212- <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label>
213- <input
214- id="sans_font"
215- type="text"
216- bind:value={config.appearance.sans_font}
217- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
218- />
219- </div>
220-
221- <div class="space-y-2">
222- <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label>
223- <input
224- id="serif_font"
225- type="text"
226- bind:value={config.appearance.serif_font}
227- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
228- />
229- </div>
230-
231- <div class="space-y-2">
232- <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label>
233- <input
234- id="monospace_font"
235- type="text"
236- bind:value={config.appearance.monospace_font}
237- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
238- />
239- </div>
240-
241- <div class="space-y-2">
242- <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label>
243- <input
244- id="font_size"
245- type="number"
246- bind:value={config.appearance.default_font_size}
247- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
248- />
249- </div>
250-
251- <div class="space-y-2">
252- <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label>
253- <select
254- id="color_scheme"
255- bind:value={config.appearance.color_scheme}
256- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
257- >
258- <option value="default">Follow System</option>
259- <option value="prefer-dark">Always Dark</option>
260- <option value="prefer-light">Always Light</option>
261- </select>
262- </div>
263-
231+ {:else if loadError}
232+ <!-- Error state -->
233+ <div class="flex flex-col items-center justify-center gap-4 py-16">
234+ <Card.Root class="max-w-md">
235+ <Card.Header>
236+ <Card.Title class="text-destructive">Failed to load config</Card.Title>
237+ <Card.Description>{loadError}</Card.Description>
238+ </Card.Header>
239+ <Card.Content>
240+ <Button variant="outline" onclick={reloadConfig}>Try again</Button>
241+ </Card.Content>
242+ </Card.Root>
243+ </div>
244+ {:else if config}
245+ <Tabs.Root bind:value={activeTab} class="w-full">
246+ <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
264247 <div class="space-y-2">
265- <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label>
266- <input
267- id="ui_scale"
268- type="number"
269- step="0.1"
270- bind:value={config.default_ui_scale}
271- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
272- />
248+ <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
249+ Config Workspace
250+ </div>
251+ <div class="text-2xl font-semibold text-foreground">dumb://config</div>
273252 </div>
253+ <Tabs.List class="bg-muted/60">
254+ <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
255+ <Tabs.Trigger value="search">Search</Tabs.Trigger>
256+ <Tabs.Trigger value="shortcuts">Shortcuts</Tabs.Trigger>
257+ </Tabs.List>
274258 </div>
275259
276- <div class="mt-8 space-y-8">
277- <div>
278- <h3 class="mb-3 text-base font-semibold">Theme: Light</h3>
279- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
280- {#each PALETTE_FIELDS as field (field.key)}
260+ <Tabs.Content value="appearance">
261+ <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
262+ <Card.Header>
263+ <Card.Title>Appearance</Card.Title>
264+ <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
265+ </Card.Header>
266+ <Card.Content class="space-y-8">
267+ <div class="grid gap-6 md:grid-cols-2">
268+ <div class="space-y-2">
269+ <Label for="sans_font">Sans-Serif Font</Label>
270+ <Input id="sans_font" bind:value={config.appearance.sans_font} />
271+ </div>
281272 <div class="space-y-2">
282- <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`}
283- >{field.label}</label
273+ <Label for="serif_font">Serif Font</Label>
274+ <Input id="serif_font" bind:value={config.appearance.serif_font} />
275+ </div>
276+ <div class="space-y-2">
277+ <Label for="monospace_font">Monospace Font</Label>
278+ <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
279+ </div>
280+ <div class="space-y-2">
281+ <Label for="font_size">Default Font Size</Label>
282+ <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
283+ </div>
284+ <div class="space-y-2">
285+ <Label for="color_scheme">Color Scheme</Label>
286+ <select
287+ id="color_scheme"
288+ bind:value={config.appearance.color_scheme}
289+ class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
284290 >
285- <div class="flex items-center gap-3">
286- <input
287- id={`light_${field.key}`}
288- type="color"
289- value={config.appearance.light_palette[field.key]}
290- oninput={(e) => {
291- const v = (e.currentTarget as HTMLInputElement).value;
292- config!.appearance.light_palette[field.key] = v;
293- }}
294- class="h-10 w-12 border border-input bg-background"
295- />
296- <input
297- type="text"
298- bind:value={config.appearance.light_palette[field.key]}
299- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
300- />
291+ <option value="default">Follow System</option>
292+ <option value="prefer-dark">Always Dark</option>
293+ <option value="prefer-light">Always Light</option>
294+ </select>
295+ </div>
296+ <div class="space-y-2">
297+ <Label for="ui_scale">UI Scale</Label>
298+ <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} />
299+ </div>
300+ </div>
301+
302+ <div class="space-y-6">
303+ <div class="space-y-3">
304+ <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
305+ Theme: Light
306+ </div>
307+ <div class="grid gap-4 md:grid-cols-2">
308+ {#each PALETTE_FIELDS as field (field.key)}
309+ <div class="space-y-2">
310+ <Label for={`light_${field.key}`}>{field.label}</Label>
311+ <div class="flex items-center gap-3">
312+ <input
313+ id={`light_${field.key}`}
314+ type="color"
315+ value={config.appearance.light_palette[field.key]}
316+ oninput={(e) => {
317+ const v = (e.currentTarget as HTMLInputElement).value;
318+ config!.appearance.light_palette[field.key] = v;
319+ }}
320+ class="h-10 w-12 border border-border bg-background"
321+ />
322+ <Input bind:value={config.appearance.light_palette[field.key]} />
323+ </div>
324+ {#if !isHexColor(config.appearance.light_palette[field.key])}
325+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
326+ {/if}
327+ </div>
328+ {/each}
301329 </div>
302- {#if !isHexColor(config.appearance.light_palette[field.key])}
303- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
304- {/if}
305330 </div>
306- {/each}
307- </div>
308- </div>
309331
310- <div>
311- <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3>
312- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
313- {#each PALETTE_FIELDS as field (field.key)}
314- <div class="space-y-2">
315- <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`}
316- >{field.label}</label
317- >
318- <div class="flex items-center gap-3">
319- <input
320- id={`dark_${field.key}`}
321- type="color"
322- value={config.appearance.dark_palette[field.key]}
323- oninput={(e) => {
324- const v = (e.currentTarget as HTMLInputElement).value;
325- config!.appearance.dark_palette[field.key] = v;
326- }}
327- class="h-10 w-12 border border-input bg-background"
328- />
329- <input
330- type="text"
331- bind:value={config.appearance.dark_palette[field.key]}
332- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
333- />
332+ <div class="space-y-3">
333+ <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
334+ Theme: Dark
335+ </div>
336+ <div class="grid gap-4 md:grid-cols-2">
337+ {#each PALETTE_FIELDS as field (field.key)}
338+ <div class="space-y-2">
339+ <Label for={`dark_${field.key}`}>{field.label}</Label>
340+ <div class="flex items-center gap-3">
341+ <input
342+ id={`dark_${field.key}`}
343+ type="color"
344+ value={config.appearance.dark_palette[field.key]}
345+ oninput={(e) => {
346+ const v = (e.currentTarget as HTMLInputElement).value;
347+ config!.appearance.dark_palette[field.key] = v;
348+ }}
349+ class="h-10 w-12 border border-border bg-background"
350+ />
351+ <Input bind:value={config.appearance.dark_palette[field.key]} />
352+ </div>
353+ {#if !isHexColor(config.appearance.dark_palette[field.key])}
354+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
355+ {/if}
356+ </div>
357+ {/each}
334358 </div>
335- {#if !isHexColor(config.appearance.dark_palette[field.key])}
336- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
337- {/if}
338359 </div>
339- {/each}
340- </div>
341- </div>
360+ </div>
361+ </Card.Content>
362+ </Card.Root>
363+ </Tabs.Content>
364+
365+ <Tabs.Content value="search">
366+ <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
367+ <Card.Header>
368+ <Card.Title>Search</Card.Title>
369+ <Card.Description>Default engine and UI scaling.</Card.Description>
370+ </Card.Header>
371+ <Card.Content class="grid gap-6 md:grid-cols-2">
372+ <div class="space-y-2">
373+ <Label for="search_engine">Default Search Engine (use %s for query)</Label>
374+ <Input id="search_engine" bind:value={config.default_search_engine} />
375+ </div>
376+ <div class="space-y-2">
377+ <Label for="ui_scale_search">UI Scale</Label>
378+ <Input id="ui_scale_search" type="number" step="0.1" bind:value={config.default_ui_scale} />
379+ </div>
380+ </Card.Content>
381+ </Card.Root>
382+ </Tabs.Content>
383+
384+ <Tabs.Content value="shortcuts">
385+ <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
386+ <Card.Header>
387+ <Card.Title>Search Shortcuts</Card.Title>
388+ <Card.Description>Map prefixes like g to URL templates.</Card.Description>
389+ </Card.Header>
390+ <Card.Content>
391+ <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
392+ </Card.Content>
393+ </Card.Root>
394+ </Tabs.Content>
395+ </Tabs.Root>
396+
397+ <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
398+ <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
399+ {#if saveSuccess}
400+ <span class="text-primary">Saved</span>
401+ {:else}
402+ <span>Ready</span>
403+ {/if}
404+ {#if themeEvents > 0}
405+ <span>Theme x{themeEvents}</span>
406+ {/if}
342407 </div>
343- </section>
344-
345- <!-- Search Section -->
346- <section class="border border-border bg-card p-6 text-card-foreground">
347- <h2 class="mb-6 text-xl font-semibold">Search</h2>
348- <div class="space-y-4">
349- <div class="space-y-2">
350- <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label>
351- <input
352- id="search_engine"
353- type="text"
354- bind:value={config.default_search_engine}
355- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
356- />
357- </div>
408+ <div class="flex items-center gap-3">
409+ <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button">
410+ Reset Defaults
411+ </Button>
412+ <Button onclick={saveConfig} disabled={saving} type="button">
413+ {saving ? "Saving…" : "Save"}
414+ </Button>
358415 </div>
359- </section>
360-
361- <div class="actions">
362- {#if saveSuccess}
363- <span class="saved">SAVED</span>
364- {/if}
365- {#if themeEvents > 0}
366- <span class="hint">THEME×{themeEvents}</span>
367- {/if}
368- <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button">
369- RESET DEFAULTS
370- </button>
371- <button onclick={saveConfig} disabled={saving} class="save-button" type="button">
372- {saving ? "SAVING…" : "SAVE"}
373- </button>
374416 </div>
375- </div>
376- {/if}
377-</div>
378-</ConfigShell>
379-
380-<style>
381- .config-content {
382- width: 100%;
383- max-width: 980px;
384- margin: 0 auto;
385- padding: 1.5rem 1.25rem 2rem;
386- }
387-
388- .stack {
389- display: flex;
390- flex-direction: column;
391- gap: 1.25rem;
392- }
393-
394- .loading {
395- padding: 2rem 0;
396- color: var(--muted-foreground);
397- letter-spacing: 0.1em;
398- font-size: 0.8rem;
399- }
400-
401- .error {
402- padding: 0.75rem 1rem;
403- border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%);
404- background: color-mix(in srgb, var(--card) 70%, #ef4444 30%);
405- display: flex;
406- flex-direction: column;
407- gap: 0.25rem;
408- }
409-
410- .actions {
411- display: flex;
412- align-items: center;
413- justify-content: flex-end;
414- gap: 0.75rem;
415- padding-top: 0.75rem;
416- border-top: 1px solid var(--border);
417- }
418-
419- .saved {
420- font-size: 0.7rem;
421- letter-spacing: 0.12em;
422- color: var(--primary, #4ade80);
423- }
424-
425- .hint {
426- font-size: 0.7rem;
427- letter-spacing: 0.12em;
428- color: var(--muted-foreground);
429- }
430-
431- .reset-button {
432- display: inline-flex;
433- align-items: center;
434- justify-content: center;
435- padding: 0.55rem 1rem;
436- font-size: 0.7rem;
437- font-weight: 600;
438- letter-spacing: 0.12em;
439- color: var(--foreground);
440- background: transparent;
441- border: 1px solid var(--border);
442- cursor: pointer;
443- transition: all 120ms ease;
444- }
445-
446- .reset-button:hover {
447- background: color-mix(in srgb, var(--card) 25%, transparent);
448- }
449-
450- .reset-button:disabled {
451- opacity: 0.6;
452- cursor: not-allowed;
453- }
454-
455- .save-button {
456- display: inline-flex;
457- align-items: center;
458- justify-content: center;
459- padding: 0.55rem 1rem;
460- font-size: 0.75rem;
461- font-weight: 600;
462- letter-spacing: 0.12em;
463- color: var(--background);
464- background: var(--primary, #4ade80);
465- border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%);
466- cursor: pointer;
467- transition: all 120ms ease;
468- }
469-
470- .save-button:hover {
471- filter: brightness(1.02);
472- }
473-
474- .save-button:disabled {
475- opacity: 0.6;
476- cursor: not-allowed;
477- }
478-</style>
479417
418+ <!-- Save error display -->
419+ {#if saveError}
420+ <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
421+ {saveError}
422+ </div>
423+ {/if}
424+ {/if}
425+ </div>
426+</ConfigShell>
@@ -1,6 +1,13 @@
1 <script lang="ts">1 <script lang="ts">
2 import { onMount } from "svelte";2 import { onMount } from "svelte";
3 import ConfigShell from "./config/ConfigShell.svelte";3 import ConfigShell from "./config/ConfigShell.svelte";
4+ import ShortcutsTable from "./config/ShortcutsTable.svelte";
5+ import { Button } from "$lib/components/ui/button";
6+ import { Input } from "$lib/components/ui/input";
7+ import { Label } from "$lib/components/ui/label";
8+ import { Spinner } from "$lib/components/ui/spinner";
9+ import * as Card from "$lib/components/ui/card";
10+ import * as Tabs from "$lib/components/ui/tabs";
4 11
5 type ColorPalette = {12 type ColorPalette = {
6 background: string;13 background: string;
@@ -12,6 +19,11 @@
12 border: string;19 border: string;
13 };20 };
14 21
22+ type SearchShortcut = {
23+ url: string;
24+ description: string;
25+ };
26+
15 type ConfigDTO = {27 type ConfigDTO = {
16 appearance: {28 appearance: {
17 sans_font: string;29 sans_font: string;
@@ -24,6 +36,7 @@
24 };36 };
25 default_ui_scale: number;37 default_ui_scale: number;
26 default_search_engine: string;38 default_search_engine: string;
39+ search_shortcuts: Record<string, SearchShortcut>;
27 };40 };
28 41
29 const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [42 const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
@@ -40,30 +53,39 @@
40 return /^#[0-9a-fA-F]{6}$/.test(value);53 return /^#[0-9a-fA-F]{6}$/.test(value);
41 }54 }
42 55
43-56+ // UI state
44- let config = $state<ConfigDTO | null>(null);
45 let loading = $state(true);57 let loading = $state(true);
46- let error = $state<string | null>(null);58+ let loadError = $state<string | null>(null);
59+ let config = $state<ConfigDTO | null>(null);
47 let saving = $state(false);60 let saving = $state(false);
48 let saveSuccess = $state(false);61 let saveSuccess = $state(false);
62+ let saveError = $state<string | null>(null);
49 let themeEvents = $state(0);63 let themeEvents = $state(0);
64+ let activeTab = $state("appearance");
50 65
51- async function fetchConfig() {66+ // Load config from API
67+ async function loadConfig() {
68+ loading = true;
69+ loadError = null;
52 try {70 try {
53 const response = await fetch("/api/config");71 const response = await fetch("/api/config");
54 if (!response.ok) throw new Error("Failed to fetch config");72 if (!response.ok) throw new Error("Failed to fetch config");
55 config = (await response.json()) as ConfigDTO;73 config = (await response.json()) as ConfigDTO;
56 } catch (e: any) {74 } catch (e: any) {
57- error = e.message;75+ loadError = e.message;
76+ console.error("[config] load failed", e);
58 } finally {77 } finally {
59 loading = false;78 loading = false;
60 }79 }
61 }80 }
62 81
82+ // Reload config (used after save to get normalized values)
83+ function reloadConfig() {
84+ loadConfig();
85+ }
86+
63 async function resetToDefaults() {87 async function resetToDefaults() {
64- error = null;
65 saveSuccess = false;88 saveSuccess = false;
66-
67 const confirmed = window.confirm(89 const confirmed = window.confirm(
68 "Reset this page to default settings? (You still need to click Save to persist.)",90 "Reset this page to default settings? (You still need to click Save to persist.)",
69 );91 );
@@ -74,7 +96,7 @@
74 if (!response.ok) throw new Error("Failed to fetch default config");96 if (!response.ok) throw new Error("Failed to fetch default config");
75 config = (await response.json()) as ConfigDTO;97 config = (await response.json()) as ConfigDTO;
76 } catch (e: any) {98 } catch (e: any) {
77- error = e.message;99+ console.error("[config] reset defaults failed", e);
78 }100 }
79 }101 }
80 102
@@ -93,7 +115,7 @@
93 async function saveConfig() {115 async function saveConfig() {
94 saving = true;116 saving = true;
95 saveSuccess = false;117 saveSuccess = false;
96- error = null;118+ saveError = null;
97 119
98 try {120 try {
99 const bridge = getWebKitBridge();121 const bridge = getWebKitBridge();
@@ -118,7 +140,7 @@
118 let timeout: number | null = window.setTimeout(() => {140 let timeout: number | null = window.setTimeout(() => {
119 if (saving) {141 if (saving) {
120 console.warn("[config] save timed out (no callback)");142 console.warn("[config] save timed out (no callback)");
121- error = "Save timed out (no response from native handler)";143+ saveError = "Save timed out (no response from native handler)";
122 saving = false;144 saving = false;
123 }145 }
124 }, 8000);146 }, 8000);
@@ -136,8 +158,8 @@
136 saveSuccess = true;158 saveSuccess = true;
137 saving = false;159 saving = false;
138 160
139- // Refresh config state from backend (in case watcher normalized values)161+ // Refresh config from backend (in case watcher normalized values)
140- fetchConfig();162+ reloadConfig();
141 163
142 setTimeout(() => {164 setTimeout(() => {
143 saveSuccess = false;165 saveSuccess = false;
@@ -146,7 +168,7 @@
146 (window as any).__dumber_config_error = (msg: unknown) => {168 (window as any).__dumber_config_error = (msg: unknown) => {
147 clearSaveTimeout();169 clearSaveTimeout();
148 console.error("[config] save error", msg);170 console.error("[config] save error", msg);
149- error = typeof msg === "string" ? msg : "Failed to save config";171+ saveError = typeof msg === "string" ? msg : "Failed to save config";
150 saving = false;172 saving = false;
151 };173 };
152 174
@@ -165,11 +187,16 @@
165 }187 }
166 } catch (e: any) {188 } catch (e: any) {
167 console.error("[config] save exception", e);189 console.error("[config] save exception", e);
168- error = e.message;190+ saveError = e.message;
169 saving = false;191 saving = false;
170 }192 }
171 }193 }
172 194
195+ function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
196+ if (!config) return;
197+ config.search_shortcuts = nextShortcuts;
198+ }
199+
173 onMount(() => {200 onMount(() => {
174 console.debug("[config] mount", {201 console.debug("[config] mount", {
175 webviewId: (window as any).__dumber_webview_id,202 webviewId: (window as any).__dumber_webview_id,
@@ -180,300 +207,220 @@
180 themeEvents += 1;207 themeEvents += 1;
181 console.debug("[config] theme changed", (e as CustomEvent).detail);208 console.debug("[config] theme changed", (e as CustomEvent).detail);
182 };209 };
183- window.addEventListener('dumber:theme-changed', onThemeChanged);210+ window.addEventListener("dumber:theme-changed", onThemeChanged);
184 211
185- fetchConfig();212+ // Load config on mount
213+ loadConfig();
186 214
187 return () => {215 return () => {
188- window.removeEventListener('dumber:theme-changed', onThemeChanged);216+ window.removeEventListener("dumber:theme-changed", onThemeChanged);
189 };217 };
190 });218 });
191 </script>219 </script>
192 220
193 <ConfigShell>221 <ConfigShell>
194- <div class="config-content">222+ <div class="flex w-full flex-col gap-6 px-6 py-8">
195 {#if loading}223 {#if loading}
196- <div class="loading">LOADING…</div>224+ <!-- Loading state with spinner -->
197- {:else if error}225+ <div class="flex flex-col items-center justify-center gap-4 py-16">
198- <div class="error">226+ <Spinner class="size-8" />
199- <strong>ERROR</strong>227+ <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground">
200- <span>{error}</span>228+ Loading configuration…
229+ </div>
201 </div>230 </div>
202- {/if}231+ {:else if loadError}
203-232+ <!-- Error state -->
204- {#if config}233+ <div class="flex flex-col items-center justify-center gap-4 py-16">
205- <div class="stack">234+ <Card.Root class="max-w-md">
206- <!-- Appearance Section -->235+ <Card.Header>
207- <section class="border border-border bg-card p-6 text-card-foreground">236+ <Card.Title class="text-destructive">Failed to load config</Card.Title>
208- <h2 class="mb-6 text-xl font-semibold">Appearance</h2>237+ <Card.Description>{loadError}</Card.Description>
209- 238+ </Card.Header>
210- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">239+ <Card.Content>
211- <div class="space-y-2">240+ <Button variant="outline" onclick={reloadConfig}>Try again</Button>
212- <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label>241+ </Card.Content>
213- <input242+ </Card.Root>
214- id="sans_font"243+ </div>
215- type="text"244+ {:else if config}
216- bind:value={config.appearance.sans_font}245+ <Tabs.Root bind:value={activeTab} class="w-full">
217- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"246+ <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
218- />
219- </div>
220-
221- <div class="space-y-2">
222- <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label>
223- <input
224- id="serif_font"
225- type="text"
226- bind:value={config.appearance.serif_font}
227- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
228- />
229- </div>
230-
231- <div class="space-y-2">
232- <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label>
233- <input
234- id="monospace_font"
235- type="text"
236- bind:value={config.appearance.monospace_font}
237- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
238- />
239- </div>
240-
241- <div class="space-y-2">
242- <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label>
243- <input
244- id="font_size"
245- type="number"
246- bind:value={config.appearance.default_font_size}
247- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
248- />
249- </div>
250-
251- <div class="space-y-2">
252- <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label>
253- <select
254- id="color_scheme"
255- bind:value={config.appearance.color_scheme}
256- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
257- >
258- <option value="default">Follow System</option>
259- <option value="prefer-dark">Always Dark</option>
260- <option value="prefer-light">Always Light</option>
261- </select>
262- </div>
263-
264 <div class="space-y-2">247 <div class="space-y-2">
265- <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label>248+ <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
266- <input249+ Config Workspace
267- id="ui_scale"250+ </div>
268- type="number"251+ <div class="text-2xl font-semibold text-foreground">dumb://config</div>
269- step="0.1"
270- bind:value={config.default_ui_scale}
271- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
272- />
273 </div>252 </div>
253+ <Tabs.List class="bg-muted/60">
254+ <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
255+ <Tabs.Trigger value="search">Search</Tabs.Trigger>
256+ <Tabs.Trigger value="shortcuts">Shortcuts</Tabs.Trigger>
257+ </Tabs.List>
274 </div>258 </div>
275 259
276- <div class="mt-8 space-y-8">260+ <Tabs.Content value="appearance">
277- <div>261+ <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
278- <h3 class="mb-3 text-base font-semibold">Theme: Light</h3>262+ <Card.Header>
279- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">263+ <Card.Title>Appearance</Card.Title>
280- {#each PALETTE_FIELDS as field (field.key)}264+ <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
265+ </Card.Header>
266+ <Card.Content class="space-y-8">
267+ <div class="grid gap-6 md:grid-cols-2">
268+ <div class="space-y-2">
269+ <Label for="sans_font">Sans-Serif Font</Label>
270+ <Input id="sans_font" bind:value={config.appearance.sans_font} />
271+ </div>
281 <div class="space-y-2">272 <div class="space-y-2">
282- <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`}273+ <Label for="serif_font">Serif Font</Label>
283- >{field.label}</label274+ <Input id="serif_font" bind:value={config.appearance.serif_font} />
275+ </div>
276+ <div class="space-y-2">
277+ <Label for="monospace_font">Monospace Font</Label>
278+ <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
279+ </div>
280+ <div class="space-y-2">
281+ <Label for="font_size">Default Font Size</Label>
282+ <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
283+ </div>
284+ <div class="space-y-2">
285+ <Label for="color_scheme">Color Scheme</Label>
286+ <select
287+ id="color_scheme"
288+ bind:value={config.appearance.color_scheme}
289+ class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
284 >290 >
285- <div class="flex items-center gap-3">291+ <option value="default">Follow System</option>
286- <input292+ <option value="prefer-dark">Always Dark</option>
287- id={`light_${field.key}`}293+ <option value="prefer-light">Always Light</option>
288- type="color"294+ </select>
289- value={config.appearance.light_palette[field.key]}295+ </div>
290- oninput={(e) => {296+ <div class="space-y-2">
291- const v = (e.currentTarget as HTMLInputElement).value;297+ <Label for="ui_scale">UI Scale</Label>
292- config!.appearance.light_palette[field.key] = v;298+ <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} />
293- }}299+ </div>
294- class="h-10 w-12 border border-input bg-background"300+ </div>
295- />301+
296- <input302+ <div class="space-y-6">
297- type="text"303+ <div class="space-y-3">
298- bind:value={config.appearance.light_palette[field.key]}304+ <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
299- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"305+ Theme: Light
300- />306+ </div>
307+ <div class="grid gap-4 md:grid-cols-2">
308+ {#each PALETTE_FIELDS as field (field.key)}
309+ <div class="space-y-2">
310+ <Label for={`light_${field.key}`}>{field.label}</Label>
311+ <div class="flex items-center gap-3">
312+ <input
313+ id={`light_${field.key}`}
314+ type="color"
315+ value={config.appearance.light_palette[field.key]}
316+ oninput={(e) => {
317+ const v = (e.currentTarget as HTMLInputElement).value;
318+ config!.appearance.light_palette[field.key] = v;
319+ }}
320+ class="h-10 w-12 border border-border bg-background"
321+ />
322+ <Input bind:value={config.appearance.light_palette[field.key]} />
323+ </div>
324+ {#if !isHexColor(config.appearance.light_palette[field.key])}
325+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
326+ {/if}
327+ </div>
328+ {/each}
301 </div>329 </div>
302- {#if !isHexColor(config.appearance.light_palette[field.key])}
303- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
304- {/if}
305 </div>330 </div>
306- {/each}
307- </div>
308- </div>
309 331
310- <div>332+ <div class="space-y-3">
311- <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3>333+ <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
312- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">334+ Theme: Dark
313- {#each PALETTE_FIELDS as field (field.key)}335+ </div>
314- <div class="space-y-2">336+ <div class="grid gap-4 md:grid-cols-2">
315- <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`}337+ {#each PALETTE_FIELDS as field (field.key)}
316- >{field.label}</label338+ <div class="space-y-2">
317- >339+ <Label for={`dark_${field.key}`}>{field.label}</Label>
318- <div class="flex items-center gap-3">340+ <div class="flex items-center gap-3">
319- <input341+ <input
320- id={`dark_${field.key}`}342+ id={`dark_${field.key}`}
321- type="color"343+ type="color"
322- value={config.appearance.dark_palette[field.key]}344+ value={config.appearance.dark_palette[field.key]}
323- oninput={(e) => {345+ oninput={(e) => {
324- const v = (e.currentTarget as HTMLInputElement).value;346+ const v = (e.currentTarget as HTMLInputElement).value;
325- config!.appearance.dark_palette[field.key] = v;347+ config!.appearance.dark_palette[field.key] = v;
326- }}348+ }}
327- class="h-10 w-12 border border-input bg-background"349+ class="h-10 w-12 border border-border bg-background"
328- />350+ />
329- <input351+ <Input bind:value={config.appearance.dark_palette[field.key]} />
330- type="text"352+ </div>
331- bind:value={config.appearance.dark_palette[field.key]}353+ {#if !isHexColor(config.appearance.dark_palette[field.key])}
332- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"354+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
333- />355+ {/if}
356+ </div>
357+ {/each}
334 </div>358 </div>
335- {#if !isHexColor(config.appearance.dark_palette[field.key])}
336- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
337- {/if}
338 </div>359 </div>
339- {/each}360+ </div>
340- </div>361+ </Card.Content>
341- </div>362+ </Card.Root>
363+ </Tabs.Content>
364+
365+ <Tabs.Content value="search">
366+ <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
367+ <Card.Header>
368+ <Card.Title>Search</Card.Title>
369+ <Card.Description>Default engine and UI scaling.</Card.Description>
370+ </Card.Header>
371+ <Card.Content class="grid gap-6 md:grid-cols-2">
372+ <div class="space-y-2">
373+ <Label for="search_engine">Default Search Engine (use %s for query)</Label>
374+ <Input id="search_engine" bind:value={config.default_search_engine} />
375+ </div>
376+ <div class="space-y-2">
377+ <Label for="ui_scale_search">UI Scale</Label>
378+ <Input id="ui_scale_search" type="number" step="0.1" bind:value={config.default_ui_scale} />
379+ </div>
380+ </Card.Content>
381+ </Card.Root>
382+ </Tabs.Content>
383+
384+ <Tabs.Content value="shortcuts">
385+ <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
386+ <Card.Header>
387+ <Card.Title>Search Shortcuts</Card.Title>
388+ <Card.Description>Map prefixes like g to URL templates.</Card.Description>
389+ </Card.Header>
390+ <Card.Content>
391+ <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
392+ </Card.Content>
393+ </Card.Root>
394+ </Tabs.Content>
395+ </Tabs.Root>
396+
397+ <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
398+ <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
399+ {#if saveSuccess}
400+ <span class="text-primary">Saved</span>
401+ {:else}
402+ <span>Ready</span>
403+ {/if}
404+ {#if themeEvents > 0}
405+ <span>Theme x{themeEvents}</span>
406+ {/if}
342 </div>407 </div>
343- </section>408+ <div class="flex items-center gap-3">
344-409+ <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button">
345- <!-- Search Section -->410+ Reset Defaults
346- <section class="border border-border bg-card p-6 text-card-foreground">411+ </Button>
347- <h2 class="mb-6 text-xl font-semibold">Search</h2>412+ <Button onclick={saveConfig} disabled={saving} type="button">
348- <div class="space-y-4">413+ {saving ? "Saving…" : "Save"}
349- <div class="space-y-2">414+ </Button>
350- <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label>
351- <input
352- id="search_engine"
353- type="text"
354- bind:value={config.default_search_engine}
355- class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
356- />
357- </div>
358 </div>415 </div>
359- </section>
360-
361- <div class="actions">
362- {#if saveSuccess}
363- <span class="saved">SAVED</span>
364- {/if}
365- {#if themeEvents > 0}
366- <span class="hint">THEME×{themeEvents}</span>
367- {/if}
368- <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button">
369- RESET DEFAULTS
370- </button>
371- <button onclick={saveConfig} disabled={saving} class="save-button" type="button">
372- {saving ? "SAVING…" : "SAVE"}
373- </button>
374 </div>416 </div>
375- </div>
376- {/if}
377-</div>
378-</ConfigShell>
379-
380-<style>
381- .config-content {
382- width: 100%;
383- max-width: 980px;
384- margin: 0 auto;
385- padding: 1.5rem 1.25rem 2rem;
386- }
387-
388- .stack {
389- display: flex;
390- flex-direction: column;
391- gap: 1.25rem;
392- }
393-
394- .loading {
395- padding: 2rem 0;
396- color: var(--muted-foreground);
397- letter-spacing: 0.1em;
398- font-size: 0.8rem;
399- }
400-
401- .error {
402- padding: 0.75rem 1rem;
403- border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%);
404- background: color-mix(in srgb, var(--card) 70%, #ef4444 30%);
405- display: flex;
406- flex-direction: column;
407- gap: 0.25rem;
408- }
409-
410- .actions {
411- display: flex;
412- align-items: center;
413- justify-content: flex-end;
414- gap: 0.75rem;
415- padding-top: 0.75rem;
416- border-top: 1px solid var(--border);
417- }
418-
419- .saved {
420- font-size: 0.7rem;
421- letter-spacing: 0.12em;
422- color: var(--primary, #4ade80);
423- }
424-
425- .hint {
426- font-size: 0.7rem;
427- letter-spacing: 0.12em;
428- color: var(--muted-foreground);
429- }
430-
431- .reset-button {
432- display: inline-flex;
433- align-items: center;
434- justify-content: center;
435- padding: 0.55rem 1rem;
436- font-size: 0.7rem;
437- font-weight: 600;
438- letter-spacing: 0.12em;
439- color: var(--foreground);
440- background: transparent;
441- border: 1px solid var(--border);
442- cursor: pointer;
443- transition: all 120ms ease;
444- }
445-
446- .reset-button:hover {
447- background: color-mix(in srgb, var(--card) 25%, transparent);
448- }
449-
450- .reset-button:disabled {
451- opacity: 0.6;
452- cursor: not-allowed;
453- }
454-
455- .save-button {
456- display: inline-flex;
457- align-items: center;
458- justify-content: center;
459- padding: 0.55rem 1rem;
460- font-size: 0.75rem;
461- font-weight: 600;
462- letter-spacing: 0.12em;
463- color: var(--background);
464- background: var(--primary, #4ade80);
465- border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%);
466- cursor: pointer;
467- transition: all 120ms ease;
468- }
469-
470- .save-button:hover {
471- filter: brightness(1.02);
472- }
473-
474- .save-button:disabled {
475- opacity: 0.6;
476- cursor: not-allowed;
477- }
478-</style>
479 417
418+ <!-- Save error display -->
419+ {#if saveError}
420+ <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
421+ {saveError}
422+ </div>
423+ {/if}
424+ {/if}
425+ </div>
426+</ConfigShell>

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