v0.26.1 75cd777

ConfigPage.svelte RawHistoryFolder

1<script lang="ts">
2  import { onMount } from "svelte";
3  import { ModeWatcher } from "mode-watcher";
4  import ConfigShell from "./config/ConfigShell.svelte";
5  import ShortcutsTable from "./config/ShortcutsTable.svelte";
6  import KeybindingsTab from "./config/KeybindingsTab.svelte";
7  import * as AlertDialog from "$lib/components/ui/alert-dialog";
8  import { Button } from "$lib/components/ui/button";
9  import { ColorPicker } from "$lib/components/ui/color-picker";
10  import { Input } from "$lib/components/ui/input";
11  import { Label } from "$lib/components/ui/label";
12  import { Spinner } from "$lib/components/ui/spinner";
13  import * as Card from "$lib/components/ui/card";
14  import * as Tabs from "$lib/components/ui/tabs";
15  import { FlaskConical, RefreshCw } from "@lucide/svelte";
16
17  function getErrorMessage(e: unknown): string {
18    if (e instanceof Error) return e.message;
19    if (typeof e === "string") return e;
20    return "Unknown error";
21  }
22
23  type ColorPalette = {
24    background: string;
25    surface: string;
26    surface_variant: string;
27    text: string;
28    muted: string;
29    accent: string;
30    border: string;
31  };
32
33  type SearchShortcut = {
34    url: string;
35    description: string;
36  };
37
38  type ResolvedPerformance = {
39    skia_cpu_threads: number;
40    skia_gpu_threads: number;
41    web_process_memory_mb: number;
42    network_process_memory_mb: number;
43    webview_pool_prewarm: number;
44    conservative_threshold: number;
45    strict_threshold: number;
46  };
47
48  type HardwareInfo = {
49    cpu_cores: number;
50    cpu_threads: number;
51    total_ram_mb: number;
52    gpu_vendor: string;
53    gpu_name: string;
54    vram_mb: number;
55  };
56
57  type CustomPerformance = {
58    skia_cpu_threads: number;
59    skia_gpu_threads: number;
60    web_process_memory_mb: number;
61    network_process_memory_mb: number;
62    webview_pool_prewarm: number;
63  };
64
65  type PerformanceConfig = {
66    profile: string;
67    custom: CustomPerformance;
68    resolved: ResolvedPerformance;
69    hardware: HardwareInfo;
70  };
71
72  type ConfigDTO = {
73    appearance: {
74      sans_font: string;
75      serif_font: string;
76      monospace_font: string;
77      default_font_size: number;
78      color_scheme: string;
79      light_palette: ColorPalette;
80      dark_palette: ColorPalette;
81    };
82    performance: PerformanceConfig;
83    default_ui_scale: number;
84    default_search_engine: string;
85    search_shortcuts: Record<string, SearchShortcut>;
86  };
87
88  const PERFORMANCE_PROFILES = [
89    { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." },
90    { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." },
91    { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." },
92    { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." },
93  ];
94
95  const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
96    { key: "background", label: "Background" },
97    { key: "surface", label: "Surface" },
98    { key: "surface_variant", label: "Surface Variant" },
99    { key: "text", label: "Text" },
100    { key: "muted", label: "Muted" },
101    { key: "accent", label: "Accent" },
102    { key: "border", label: "Border" },
103  ];
104
105  function isHexColor(value: string): boolean {
106    return /^#[0-9a-fA-F]{6}$/.test(value);
107  }
108
109  // UI state
110  let loading = $state(true);
111  let loadError = $state<string | null>(null);
112  let config = $state<ConfigDTO | null>(null);
113  let saving = $state(false);
114  let saveSuccess = $state(false);
115  let saveError = $state<string | null>(null);
116  let showRestartWarning = $state(false);
117  let themeEvents = $state(0);
118  let activeTab = $state("appearance");
119
120  // Load config from API
121  async function loadConfig() {
122    loading = true;
123    loadError = null;
124    try {
125      const response = await fetch("/api/config");
126      if (!response.ok) throw new Error("Failed to fetch config");
127      config = (await response.json()) as ConfigDTO;
128    } catch (e: unknown) {
129      loadError = getErrorMessage(e);
130      console.error("[config] load failed", e);
131    } finally {
132      loading = false;
133    }
134  }
135
136  // Reload config (used after save to get normalized values)
137  function reloadConfig() {
138    loadConfig();
139  }
140
141  let resetDialogOpen = $state(false);
142
143  async function doResetToDefaults() {
144    saveSuccess = false;
145    try {
146      const response = await fetch("/api/config/default");
147      if (!response.ok) throw new Error("Failed to fetch default config");
148      config = (await response.json()) as ConfigDTO;
149    } catch (e: unknown) {
150      console.error("[config] reset defaults failed", e);
151    }
152    resetDialogOpen = false;
153  }
154
155  function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
156    const bridge = window.webkit?.messageHandlers?.dumber;
157    if (bridge && typeof bridge.postMessage === "function") {
158      return bridge;
159    }
160    return null;
161  }
162
163  function getWebViewId(): number {
164    return window.__dumber_webview_id || 0;
165  }
166
167  async function saveConfig() {
168    saving = true;
169    saveSuccess = false;
170    saveError = null;
171
172    try {
173      const bridge = getWebKitBridge();
174      const webviewId = getWebViewId();
175
176      console.debug("[config] save click", {
177        hasBridge: Boolean(bridge),
178        webviewId,
179        hasConfig: Boolean(config),
180      });
181
182      if (!bridge) {
183        throw new Error("WebKit bridge not available (not running inside Dumber)");
184      }
185      if (!config) {
186        throw new Error("Config not loaded");
187      }
188
189      let timeout: number | null = window.setTimeout(() => {
190        if (saving) {
191          console.warn("[config] save timed out (no callback)");
192          saveError = "Save timed out (no response from native handler)";
193          saving = false;
194        }
195      }, 8000);
196
197      const clearSaveTimeout = () => {
198        if (timeout != null) {
199          window.clearTimeout(timeout);
200          timeout = null;
201        }
202      };
203
204      window.__dumber_config_saved = (resp?: unknown) => {
205        clearSaveTimeout();
206        console.debug("[config] save success", resp);
207        saveSuccess = true;
208        saving = false;
209
210        // Show restart warning if performance tab was active
211        if (activeTab === "performance") {
212          showRestartWarning = true;
213        }
214
215        // Refresh config from backend (in case watcher normalized values)
216        reloadConfig();
217
218        setTimeout(() => {
219          saveSuccess = false;
220        }, 3000);
221      };
222      window.__dumber_config_error = (msg: unknown) => {
223        clearSaveTimeout();
224        console.error("[config] save error", msg);
225        saveError = typeof msg === "string" ? msg : "Failed to save config";
226        saving = false;
227      };
228
229      const payload = $state.snapshot(config);
230
231      try {
232        bridge.postMessage({
233          type: "save_config",
234          webview_id: webviewId,
235          payload,
236        });
237      } catch (postErr) {
238        console.error("[config] postMessage threw", postErr);
239        throw postErr;
240      }
241    } catch (e: unknown) {
242      console.error("[config] save exception", e);
243      saveError = getErrorMessage(e);
244      saving = false;
245    }
246  }
247
248  function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
249    if (!config) return;
250    config.search_shortcuts = nextShortcuts;
251  }
252
253  onMount(() => {
254    console.debug("[config] mount", {
255      webviewId: window.__dumber_webview_id,
256      hasBridge: Boolean(window.webkit?.messageHandlers?.dumber),
257    });
258
259    const onThemeChanged = (e: Event) => {
260      themeEvents += 1;
261      console.debug("[config] theme changed", (e as CustomEvent).detail);
262    };
263    window.addEventListener("dumber:theme-changed", onThemeChanged);
264
265    // Load config on mount
266    loadConfig();
267
268    return () => {
269      window.removeEventListener("dumber:theme-changed", onThemeChanged);
270    };
271  });
272</script>
273
274<ModeWatcher />
275<ConfigShell>
276  <div class="flex w-full flex-col gap-6 px-6 py-8">
277    {#if loading}
278      <!-- Loading state with spinner -->
279      <div class="flex flex-col items-center justify-center gap-4 py-16">
280        <Spinner class="size-8" />
281        <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground">
282          Loading configuration…
283        </div>
284      </div>
285    {:else if loadError}
286      <!-- Error state -->
287      <div class="flex flex-col items-center justify-center gap-4 py-16">
288        <Card.Root class="max-w-md">
289          <Card.Header>
290            <Card.Title class="text-destructive">Failed to load config</Card.Title>
291            <Card.Description>{loadError}</Card.Description>
292          </Card.Header>
293          <Card.Content>
294            <Button variant="outline" onclick={reloadConfig}>Try again</Button>
295          </Card.Content>
296        </Card.Root>
297      </div>
298    {:else if config}
299      <Tabs.Root bind:value={activeTab} class="w-full">
300        <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
301          <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
302            Config Workspace
303          </div>
304          <Tabs.List class="bg-muted/60">
305            <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
306            <Tabs.Trigger value="search">Search</Tabs.Trigger>
307            <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
308            <Tabs.Trigger value="performance">Performance</Tabs.Trigger>
309          </Tabs.List>
310        </div>
311
312        <Tabs.Content value="appearance">
313          <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
314            <Card.Header>
315              <Card.Title>Appearance</Card.Title>
316              <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
317            </Card.Header>
318            <Card.Content class="space-y-8">
319              <div class="grid gap-6 md:grid-cols-2">
320                <div class="space-y-2">
321                  <Label for="sans_font">Sans-Serif Font</Label>
322                  <Input id="sans_font" bind:value={config.appearance.sans_font} />
323                </div>
324                <div class="space-y-2">
325                  <Label for="serif_font">Serif Font</Label>
326                  <Input id="serif_font" bind:value={config.appearance.serif_font} />
327                </div>
328                <div class="space-y-2">
329                  <Label for="monospace_font">Monospace Font</Label>
330                  <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
331                </div>
332                <div class="space-y-2">
333                  <Label for="font_size">Default Font Size</Label>
334                  <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
335                </div>
336                <div class="space-y-2">
337                  <Label for="color_scheme">Color Scheme</Label>
338                  <select
339                    id="color_scheme"
340                    bind:value={config.appearance.color_scheme}
341                    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"
342                  >
343                    <option value="default">Follow System</option>
344                    <option value="prefer-dark">Always Dark</option>
345                    <option value="prefer-light">Always Light</option>
346                  </select>
347                </div>
348                <div class="space-y-2">
349                  <Label for="ui_scale">UI Scale</Label>
350                  <Input
351                    id="ui_scale"
352                    type="number"
353                    step="0.1"
354                    bind:value={config.default_ui_scale}
355                  />
356                </div>
357              </div>
358
359              <div class="space-y-6">
360                <div class="space-y-3">
361                  <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
362                    Theme: Light
363                  </div>
364                  <div class="grid gap-4 md:grid-cols-2">
365                    {#each PALETTE_FIELDS as field (field.key)}
366                      <div class="space-y-2">
367                        <Label for={`light_${field.key}`}>{field.label}</Label>
368                        <div class="flex items-center gap-3">
369                          <ColorPicker
370                            id={`light_${field.key}`}
371                            value={config.appearance.light_palette[field.key]}
372                            onValueChange={(v) => {
373                              config!.appearance.light_palette[field.key] = v;
374                            }}
375                          />
376                          <Input bind:value={config.appearance.light_palette[field.key]} />
377                        </div>
378                        {#if !isHexColor(config.appearance.light_palette[field.key])}
379                          <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
380                        {/if}
381                      </div>
382                    {/each}
383                  </div>
384                </div>
385
386                <div class="space-y-3">
387                  <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
388                    Theme: Dark
389                  </div>
390                  <div class="grid gap-4 md:grid-cols-2">
391                    {#each PALETTE_FIELDS as field (field.key)}
392                      <div class="space-y-2">
393                        <Label for={`dark_${field.key}`}>{field.label}</Label>
394                        <div class="flex items-center gap-3">
395                          <ColorPicker
396                            id={`dark_${field.key}`}
397                            value={config.appearance.dark_palette[field.key]}
398                            onValueChange={(v) => {
399                              config!.appearance.dark_palette[field.key] = v;
400                            }}
401                          />
402                          <Input bind:value={config.appearance.dark_palette[field.key]} />
403                        </div>
404                        {#if !isHexColor(config.appearance.dark_palette[field.key])}
405                          <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
406                        {/if}
407                      </div>
408                    {/each}
409                  </div>
410                </div>
411              </div>
412            </Card.Content>
413          </Card.Root>
414        </Tabs.Content>
415
416        <Tabs.Content value="search">
417          <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
418            <Card.Header>
419              <Card.Title>Search</Card.Title>
420              <Card.Description>Default engine and shortcuts.</Card.Description>
421            </Card.Header>
422            <Card.Content class="space-y-8">
423              <div class="space-y-2">
424                <Label for="search_engine">Default Search Engine (use %s for query)</Label>
425                <Input id="search_engine" bind:value={config.default_search_engine} />
426              </div>
427
428              <div class="space-y-3">
429                <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
430                  Search Shortcuts
431                </div>
432                <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p>
433                <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
434              </div>
435            </Card.Content>
436          </Card.Root>
437        </Tabs.Content>
438
439        <Tabs.Content value="keybindings">
440          <KeybindingsTab />
441        </Tabs.Content>
442
443        <Tabs.Content value="performance">
444          <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
445            <Card.Header>
446              <Card.Title>Performance Profile</Card.Title>
447              <Card.Description>
448                WebKitGTK rendering and memory tuning presets.
449              </Card.Description>
450            </Card.Header>
451            <Card.Content class="space-y-6">
452              <!-- Detected Hardware Card -->
453              {#if config.performance?.hardware}
454                {@const hw = config.performance.hardware}
455                <div class="rounded-md border border-border bg-muted/30 px-4 py-3">
456                  <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
457                    Detected Hardware
458                  </div>
459                  <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
460                    <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div>
461                    <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div>
462                    <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div>
463                    <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div>
464                  </div>
465                </div>
466              {/if}
467
468              <!-- Experimental feature warning -->
469              <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3">
470                <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" />
471                <div class="space-y-1">
472                  <p class="text-sm font-medium text-info">Experimental Feature</p>
473                  <p class="text-xs text-muted-foreground">
474                    Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version.
475                  </p>
476                </div>
477              </div>
478
479              <!-- Profile selector -->
480              {#if config.performance}
481              <div class="space-y-4">
482                {#each PERFORMANCE_PROFILES as profile (profile.value)}
483                  <label
484                    class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}"
485                  >
486                    <input
487                      type="radio"
488                      name="performance_profile"
489                      value={profile.value}
490                      checked={config.performance.profile === profile.value}
491                      onchange={() => config!.performance.profile = profile.value}
492                      class="mt-1 accent-primary"
493                    />
494                    <div class="space-y-1">
495                      <div class="font-medium">{profile.label}</div>
496                      <div class="text-sm text-muted-foreground">{profile.description}</div>
497                    </div>
498                  </label>
499                {/each}
500              </div>
501              {:else}
502              <div class="text-sm text-muted-foreground">
503                Performance settings not available. This may be due to a configuration error.
504              </div>
505              {/if}
506
507              <!-- Custom profile fields -->
508              {#if config.performance?.profile === "custom"}
509                <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4">
510                  <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
511                    Custom Settings
512                  </div>
513                  <div class="grid gap-4 md:grid-cols-2">
514                    <div class="space-y-2">
515                      <Label for="skia_cpu_threads">Skia CPU Threads</Label>
516                      <Input
517                        id="skia_cpu_threads"
518                        type="number"
519                        min="0"
520                        max="8"
521                        bind:value={config.performance.custom.skia_cpu_threads}
522                      />
523                      <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p>
524                    </div>
525                    <div class="space-y-2">
526                      <Label for="skia_gpu_threads">Skia GPU Threads</Label>
527                      <Input
528                        id="skia_gpu_threads"
529                        type="number"
530                        min="-1"
531                        bind:value={config.performance.custom.skia_gpu_threads}
532                      />
533                      <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p>
534                    </div>
535                    <div class="space-y-2">
536                      <Label for="web_process_memory">Web Process Memory (MB)</Label>
537                      <Input
538                        id="web_process_memory"
539                        type="number"
540                        min="0"
541                        step="256"
542                        bind:value={config.performance.custom.web_process_memory_mb}
543                      />
544                      <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
545                    </div>
546                    <div class="space-y-2">
547                      <Label for="network_process_memory">Network Process Memory (MB)</Label>
548                      <Input
549                        id="network_process_memory"
550                        type="number"
551                        min="0"
552                        step="128"
553                        bind:value={config.performance.custom.network_process_memory_mb}
554                      />
555                      <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
556                    </div>
557                    <div class="space-y-2">
558                      <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label>
559                      <Input
560                        id="webview_pool_prewarm"
561                        type="number"
562                        min="0"
563                        max="20"
564                        bind:value={config.performance.custom.webview_pool_prewarm}
565                      />
566                      <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p>
567                    </div>
568                  </div>
569                </div>
570              {/if}
571
572              <!-- Resolved values display -->
573              {#if config.performance?.resolved}
574                <div class="space-y-3">
575                  <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
576                    Resolved Settings (applied on restart)
577                  </div>
578                  <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm">
579                    <div class="space-y-1">
580                      <div class="flex justify-between">
581                        <span class="font-medium">Skia CPU Threads</span>
582                        <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span>
583                      </div>
584                      <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p>
585                    </div>
586                    <div class="space-y-1">
587                      <div class="flex justify-between">
588                        <span class="font-medium">Skia GPU Threads</span>
589                        <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span>
590                      </div>
591                      <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p>
592                    </div>
593                    <div class="space-y-1">
594                      <div class="flex justify-between">
595                        <span class="font-medium">Web Process Memory</span>
596                        <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span>
597                      </div>
598                      <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p>
599                    </div>
600                    <div class="space-y-1">
601                      <div class="flex justify-between">
602                        <span class="font-medium">Network Process Memory</span>
603                        <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span>
604                      </div>
605                      <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p>
606                    </div>
607                    <div class="space-y-1">
608                      <div class="flex justify-between">
609                        <span class="font-medium">WebView Pool Prewarm</span>
610                        <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span>
611                      </div>
612                      <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p>
613                    </div>
614                  </div>
615                </div>
616              {/if}
617            </Card.Content>
618          </Card.Root>
619        </Tabs.Content>
620      </Tabs.Root>
621
622      <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
623        <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
624          {#if saveSuccess}
625            <span class="text-primary">Saved</span>
626          {:else}
627            <span>Ready</span>
628          {/if}
629          {#if themeEvents > 0}
630            <span>Theme x{themeEvents}</span>
631          {/if}
632        </div>
633        {#if activeTab !== "keybindings"}
634          <div class="flex items-center gap-3">
635            <AlertDialog.Root bind:open={resetDialogOpen}>
636              <AlertDialog.Trigger disabled={saving}>
637                {#snippet child({ props })}
638                  <Button variant="outline" {...props} type="button">
639                    Reset Defaults
640                  </Button>
641                {/snippet}
642              </AlertDialog.Trigger>
643              <AlertDialog.Content>
644                <AlertDialog.Header>
645                  <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
646                  <AlertDialog.Description>
647                    This will reset all settings on this page to their default values.
648                    You will still need to click Save to persist the changes.
649                  </AlertDialog.Description>
650                </AlertDialog.Header>
651                <AlertDialog.Footer>
652                  <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
653                  <AlertDialog.Action onclick={doResetToDefaults}>
654                    Reset Defaults
655                  </AlertDialog.Action>
656                </AlertDialog.Footer>
657              </AlertDialog.Content>
658            </AlertDialog.Root>
659            <Button onclick={saveConfig} disabled={saving} type="button">
660              {saving ? "Saving…" : "Save"}
661            </Button>
662          </div>
663        {/if}
664      </div>
665
666      <!-- Save error display -->
667      {#if saveError}
668        <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
669          {saveError}
670        </div>
671      {/if}
672
673      <!-- Restart warning after saving performance settings -->
674      {#if showRestartWarning}
675        <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3">
676          <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" />
677          <div class="space-y-1">
678            <p class="text-sm font-medium text-warning">Restart Required</p>
679            <p class="text-xs text-muted-foreground">
680              Performance settings are applied at browser startup. Restart Dumber for changes to take effect.
681            </p>
682          </div>
683        </div>
684      {/if}
685    {/if}
686  </div>
687</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