v0.22.0 a85da87

ConfigPage.svelte RawHistoryFolder

1<script lang="ts">
2  import { onMount } from "svelte";
3  import ConfigShell from "./config/ConfigShell.svelte";
4  import ShortcutsTable from "./config/ShortcutsTable.svelte";
5  import * as AlertDialog from "$lib/components/ui/alert-dialog";
6  import { Button } from "$lib/components/ui/button";
7  import { ColorPicker } from "$lib/components/ui/color-picker";
8  import { Input } from "$lib/components/ui/input";
9  import { Label } from "$lib/components/ui/label";
10  import { Spinner } from "$lib/components/ui/spinner";
11  import * as Card from "$lib/components/ui/card";
12  import * as Tabs from "$lib/components/ui/tabs";
13
14  type ColorPalette = {
15    background: string;
16    surface: string;
17    surface_variant: string;
18    text: string;
19    muted: string;
20    accent: string;
21    border: string;
22  };
23
24  type SearchShortcut = {
25    url: string;
26    description: string;
27  };
28
29  type ConfigDTO = {
30    appearance: {
31      sans_font: string;
32      serif_font: string;
33      monospace_font: string;
34      default_font_size: number;
35      color_scheme: string;
36      light_palette: ColorPalette;
37      dark_palette: ColorPalette;
38    };
39    default_ui_scale: number;
40    default_search_engine: string;
41    search_shortcuts: Record<string, SearchShortcut>;
42  };
43
44  const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
45    { key: "background", label: "Background" },
46    { key: "surface", label: "Surface" },
47    { key: "surface_variant", label: "Surface Variant" },
48    { key: "text", label: "Text" },
49    { key: "muted", label: "Muted" },
50    { key: "accent", label: "Accent" },
51    { key: "border", label: "Border" },
52  ];
53
54  function isHexColor(value: string): boolean {
55    return /^#[0-9a-fA-F]{6}$/.test(value);
56  }
57
58  // UI state
59  let loading = $state(true);
60  let loadError = $state<string | null>(null);
61  let config = $state<ConfigDTO | null>(null);
62  let saving = $state(false);
63  let saveSuccess = $state(false);
64  let saveError = $state<string | null>(null);
65  let themeEvents = $state(0);
66  let activeTab = $state("appearance");
67
68  // Load config from API
69  async function loadConfig() {
70    loading = true;
71    loadError = null;
72    try {
73      const response = await fetch("/api/config");
74      if (!response.ok) throw new Error("Failed to fetch config");
75      config = (await response.json()) as ConfigDTO;
76    } catch (e: any) {
77      loadError = e.message;
78      console.error("[config] load failed", e);
79    } finally {
80      loading = false;
81    }
82  }
83
84  // Reload config (used after save to get normalized values)
85  function reloadConfig() {
86    loadConfig();
87  }
88
89  let resetDialogOpen = $state(false);
90
91  async function doResetToDefaults() {
92    saveSuccess = false;
93    try {
94      const response = await fetch("/api/config/default");
95      if (!response.ok) throw new Error("Failed to fetch default config");
96      config = (await response.json()) as ConfigDTO;
97    } catch (e: any) {
98      console.error("[config] reset defaults failed", e);
99    }
100    resetDialogOpen = false;
101  }
102
103  function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
104    const bridge = (window as any).webkit?.messageHandlers?.dumber;
105    if (bridge && typeof bridge.postMessage === "function") {
106      return bridge;
107    }
108    return null;
109  }
110
111  function getWebViewId(): number {
112    return (window as any).__dumber_webview_id || 0;
113  }
114
115  async function saveConfig() {
116    saving = true;
117    saveSuccess = false;
118    saveError = null;
119
120    try {
121      const bridge = getWebKitBridge();
122      const webviewId = getWebViewId();
123
124      console.debug("[config] save click", {
125        hasBridge: Boolean(bridge),
126        webviewId,
127        hasConfig: Boolean(config),
128      });
129
130      if (!bridge) {
131        throw new Error("WebKit bridge not available (not running inside Dumber)");
132      }
133      if (!webviewId) {
134        throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
135      }
136      if (!config) {
137        throw new Error("Config not loaded");
138      }
139
140      let timeout: number | null = window.setTimeout(() => {
141        if (saving) {
142          console.warn("[config] save timed out (no callback)");
143          saveError = "Save timed out (no response from native handler)";
144          saving = false;
145        }
146      }, 8000);
147
148      const clearSaveTimeout = () => {
149        if (timeout != null) {
150          window.clearTimeout(timeout);
151          timeout = null;
152        }
153      };
154
155      (window as any).__dumber_config_saved = (resp?: unknown) => {
156        clearSaveTimeout();
157        console.debug("[config] save success", resp);
158        saveSuccess = true;
159        saving = false;
160
161        // Refresh config from backend (in case watcher normalized values)
162        reloadConfig();
163
164        setTimeout(() => {
165          saveSuccess = false;
166        }, 3000);
167      };
168      (window as any).__dumber_config_error = (msg: unknown) => {
169        clearSaveTimeout();
170        console.error("[config] save error", msg);
171        saveError = typeof msg === "string" ? msg : "Failed to save config";
172        saving = false;
173      };
174
175      const payload = $state.snapshot(config);
176
177      try {
178        bridge.postMessage({
179          type: "save_config",
180          webview_id: webviewId,
181          payload,
182        });
183        console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length });
184      } catch (postErr) {
185        console.error("[config] postMessage threw", postErr);
186        throw postErr;
187      }
188    } catch (e: any) {
189      console.error("[config] save exception", e);
190      saveError = e.message;
191      saving = false;
192    }
193  }
194
195  function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
196    if (!config) return;
197    config.search_shortcuts = nextShortcuts;
198  }
199
200  onMount(() => {
201    console.debug("[config] mount", {
202      webviewId: (window as any).__dumber_webview_id,
203      hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber),
204    });
205
206    const onThemeChanged = (e: Event) => {
207      themeEvents += 1;
208      console.debug("[config] theme changed", (e as CustomEvent).detail);
209    };
210    window.addEventListener("dumber:theme-changed", onThemeChanged);
211
212    // Load config on mount
213    loadConfig();
214
215    return () => {
216      window.removeEventListener("dumber:theme-changed", onThemeChanged);
217    };
218  });
219</script>
220
221<ConfigShell>
222  <div class="flex w-full flex-col gap-6 px-6 py-8">
223    {#if loading}
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>
230      </div>
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">
247          <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
248            Config Workspace
249          </div>
250          <Tabs.List class="bg-muted/60">
251            <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
252            <Tabs.Trigger value="search">Search</Tabs.Trigger>
253          </Tabs.List>
254        </div>
255
256        <Tabs.Content value="appearance">
257          <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
258            <Card.Header>
259              <Card.Title>Appearance</Card.Title>
260              <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
261            </Card.Header>
262            <Card.Content class="space-y-8">
263              <div class="grid gap-6 md:grid-cols-2">
264                <div class="space-y-2">
265                  <Label for="sans_font">Sans-Serif Font</Label>
266                  <Input id="sans_font" bind:value={config.appearance.sans_font} />
267                </div>
268                <div class="space-y-2">
269                  <Label for="serif_font">Serif Font</Label>
270                  <Input id="serif_font" bind:value={config.appearance.serif_font} />
271                </div>
272                <div class="space-y-2">
273                  <Label for="monospace_font">Monospace Font</Label>
274                  <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
275                </div>
276                <div class="space-y-2">
277                  <Label for="font_size">Default Font Size</Label>
278                  <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
279                </div>
280                <div class="space-y-2">
281                  <Label for="color_scheme">Color Scheme</Label>
282                  <select
283                    id="color_scheme"
284                    bind:value={config.appearance.color_scheme}
285                    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"
286                  >
287                    <option value="default">Follow System</option>
288                    <option value="prefer-dark">Always Dark</option>
289                    <option value="prefer-light">Always Light</option>
290                  </select>
291                </div>
292                <div class="space-y-2">
293                  <Label for="ui_scale">UI Scale</Label>
294                  <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} />
295                </div>
296              </div>
297
298              <div class="space-y-6">
299                <div class="space-y-3">
300                  <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
301                    Theme: Light
302                  </div>
303                  <div class="grid gap-4 md:grid-cols-2">
304                    {#each PALETTE_FIELDS as field (field.key)}
305                      <div class="space-y-2">
306                        <Label for={`light_${field.key}`}>{field.label}</Label>
307                        <div class="flex items-center gap-3">
308                          <ColorPicker
309                            id={`light_${field.key}`}
310                            value={config.appearance.light_palette[field.key]}
311                            onValueChange={(v) => {
312                              config!.appearance.light_palette[field.key] = v;
313                            }}
314                          />
315                          <Input bind:value={config.appearance.light_palette[field.key]} />
316                        </div>
317                        {#if !isHexColor(config.appearance.light_palette[field.key])}
318                          <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
319                        {/if}
320                      </div>
321                    {/each}
322                  </div>
323                </div>
324
325                <div class="space-y-3">
326                  <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
327                    Theme: Dark
328                  </div>
329                  <div class="grid gap-4 md:grid-cols-2">
330                    {#each PALETTE_FIELDS as field (field.key)}
331                      <div class="space-y-2">
332                        <Label for={`dark_${field.key}`}>{field.label}</Label>
333                        <div class="flex items-center gap-3">
334                          <ColorPicker
335                            id={`dark_${field.key}`}
336                            value={config.appearance.dark_palette[field.key]}
337                            onValueChange={(v) => {
338                              config!.appearance.dark_palette[field.key] = v;
339                            }}
340                          />
341                          <Input bind:value={config.appearance.dark_palette[field.key]} />
342                        </div>
343                        {#if !isHexColor(config.appearance.dark_palette[field.key])}
344                          <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
345                        {/if}
346                      </div>
347                    {/each}
348                  </div>
349                </div>
350              </div>
351            </Card.Content>
352          </Card.Root>
353        </Tabs.Content>
354
355        <Tabs.Content value="search">
356          <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
357            <Card.Header>
358              <Card.Title>Search</Card.Title>
359              <Card.Description>Default engine and shortcuts.</Card.Description>
360            </Card.Header>
361            <Card.Content class="space-y-8">
362              <div class="space-y-2">
363                <Label for="search_engine">Default Search Engine (use %s for query)</Label>
364                <Input id="search_engine" bind:value={config.default_search_engine} />
365              </div>
366
367              <div class="space-y-3">
368                <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
369                  Search Shortcuts
370                </div>
371                <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p>
372                <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
373              </div>
374            </Card.Content>
375          </Card.Root>
376        </Tabs.Content>
377      </Tabs.Root>
378
379      <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
380        <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
381          {#if saveSuccess}
382            <span class="text-primary">Saved</span>
383          {:else}
384            <span>Ready</span>
385          {/if}
386          {#if themeEvents > 0}
387            <span>Theme x{themeEvents}</span>
388          {/if}
389        </div>
390        <div class="flex items-center gap-3">
391          <AlertDialog.Root bind:open={resetDialogOpen}>
392            <AlertDialog.Trigger disabled={saving}>
393              {#snippet child({ props })}
394                <Button variant="outline" {...props} type="button">
395                  Reset Defaults
396                </Button>
397              {/snippet}
398            </AlertDialog.Trigger>
399            <AlertDialog.Content>
400              <AlertDialog.Header>
401                <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
402                <AlertDialog.Description>
403                  This will reset all settings on this page to their default values.
404                  You will still need to click Save to persist the changes.
405                </AlertDialog.Description>
406              </AlertDialog.Header>
407              <AlertDialog.Footer>
408                <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
409                <AlertDialog.Action onclick={doResetToDefaults}>
410                  Reset Defaults
411                </AlertDialog.Action>
412              </AlertDialog.Footer>
413            </AlertDialog.Content>
414          </AlertDialog.Root>
415          <Button onclick={saveConfig} disabled={saving} type="button">
416            {saving ? "Saving…" : "Save"}
417          </Button>
418        </div>
419      </div>
420
421      <!-- Save error display -->
422      {#if saveError}
423        <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
424          {saveError}
425        </div>
426      {/if}
427    {/if}
428  </div>
429</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