v0.23.2 bfd5eb4

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