Move settings into a standalone Settings window

The tab-bar popover had outgrown itself. Settings now open in their own
native window (a fourth window kind, built like the connection manager but
not modal): a sidebar with Appearance, Transfers, Editor, File list and
About, holding every popover row plus the visible-columns choice. Each
control is an ordinary settings save, so the other windows follow live
while it stays open.

The gear button in the tab bar, Ctrl+, and the macOS app menu's
"Settings..." (Cmd+,) open it. On macOS Cmd+W arrives as the main window's
close-tab menu action, so bun closes the focused Settings window first
instead of a main-window tab.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:55:15 +02:00
co-authored by Claude
parent f3bec7aeaf
commit 141d675bf7
28 changed files with 743 additions and 405 deletions
@@ -0,0 +1,303 @@
<div class="flex h-screen flex-col bg-background text-foreground">
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
elsewhere the native titlebar sits above and this is a compact strip -->
<header
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.electrobun-webkit-app-region-drag]="isMacChrome"
[class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome"
>
<ng-icon name="tdesignSetting" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
</header>
<div class="flex min-h-0 flex-1">
<!-- section list -->
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
@for (s of sections; track s) {
<button
(click)="section.set(s)"
class="rounded-md px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
[class.bg-accent]="section() === s"
[class.font-medium]="section() === s"
[class.text-foreground]="section() === s"
[class.text-muted-foreground]="section() !== s"
>
{{ i18n.t('options.sections.' + s) }}
</button>
}
</nav>
<!-- the chosen section's rows -->
<main class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
<h2 class="text-sm font-semibold">{{ i18n.t('options.sections.' + section()) }}</h2>
<div class="mt-4 flex max-w-md flex-col gap-3">
@switch (section()) {
@case ('appearance') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.language') }}</span>
<select
(change)="settings.setLocale($any($event.target).value)"
class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
>
<option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }}
</option>
@for (loc of localeOptions; track loc) {
<option [value]="loc" [selected]="settings.locale() === loc">
{{ localeLabels[loc] }}
</option>
}
</select>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.theme.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (theme of themes; track theme) {
<button
(click)="settings.setTheme(theme)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ i18n.t('options.theme.' + theme) }}
</button>
}
</div>
</div>
@if (settings.effectiveDark()) {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.tone.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (tone of darkTones; track tone) {
<button
(click)="settings.setDarkTone(tone)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ i18n.t('options.tone.' + tone) }}
</button>
}
</div>
</div>
}
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.accent.label') }}</span>
<div class="flex items-center gap-1.5">
@for (tone of accents; track tone.value) {
<button
(click)="settings.setAccent(tone.value)"
[title]="i18n.t('options.accentNames.' + tone.value)"
class="size-3 rounded-full border border-foreground/15"
[style.background-color]="tone.swatch"
[class.ring-2]="settings.accent() === tone.value"
[class.ring-foreground]="settings.accent() === tone.value"
[class.ring-offset-1]="settings.accent() === tone.value"
[class.ring-offset-background]="settings.accent() === tone.value"
></button>
}
</div>
</div>
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
steps. One lever moves the whole UI (root font-size; everything
is rem), this window included. -->
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.interface.label') }}</span>
<div class="flex items-center rounded-md border border-border p-0.5">
<button
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
[disabled]="settings.uiScale() <= 0.5"
[title]="i18n.t('options.interface.smaller')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
−
</button>
<button
(click)="settings.setUiScale(1)"
[title]="i18n.t('options.interface.reset')"
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
>
{{ scalePercent() }}
</button>
<button
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
[disabled]="settings.uiScale() >= 4"
[title]="i18n.t('options.interface.larger')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
+
</button>
</div>
</div>
}
@case ('transfers') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.speedUnit.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (unit of speedUnits; track unit.value) {
<button
(click)="settings.setSpeedUnit(unit.value)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
>
{{ unit.label }}
</button>
}
</div>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.transfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of transferModes; track mode) {
<button
(click)="settings.setTransferMode(mode)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode"
>
{{ i18n.t('options.transfers.' + mode) }}
</button>
}
</div>
</div>
@if (settings.transferMode() === 'multi') {
<div class="flex items-center justify-between gap-2 pl-3">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (count of concurrencyChoices; track count) {
<button
(click)="settings.setTransferConcurrency(count)"
class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
>
{{ count }}
</button>
}
</div>
</div>
}
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.finishedTransfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setAutoClearCompleted(true)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.autoClear') }}
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.keep') }}
</button>
</div>
</div>
}
@case ('editor') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.editor.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setEditor('builtin')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
{{ i18n.t('options.editor.builtin') }}
</button>
<button
(click)="settings.setEditor('custom')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
{{ i18n.t('options.editor.custom') }}
</button>
</div>
</div>
@if (settings.editor() === 'custom') {
<div class="flex items-center justify-between gap-2 pl-3">
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
{{ editorAppName() }}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
(click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
[title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignClose" size="0.7rem" />
</button>
}
</div>
</div>
}
}
@case ('fileList') {
<span class="text-xs">{{ i18n.t('options.columns') }}</span>
<div class="flex flex-col gap-2 pl-3">
@for (key of columnKeys; track key) {
<label class="flex items-center gap-2 text-xs">
<input
type="checkbox"
class="accent-primary"
[checked]="settings.columns().includes(key)"
(change)="settings.toggleColumn(key)"
/>
{{ i18n.t('columns.' + key) }}
</label>
}
</div>
}
@case ('about') {
<!-- the old popover footer: opens the native About box -->
<button
(click)="showAbout()"
class="self-start rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
}
}
</div>
</main>
</div>
</div>
@@ -0,0 +1,105 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import type { AccentTone } from '@shared/types';
import { COLUMN_KEYS } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const;
type Section = (typeof SECTIONS)[number];
const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const;
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
// pages (the live --primary values would vanish against one of them). Names are
// translated via options.accentNames.<value>.
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
];
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
/**
* Root of the Settings window (APP_MODE 'settings'). Every control is a plain
* SettingsService setter: the save goes to bun, which rebroadcasts it, so the
* other windows follow live while this one stays open.
*/
@Component({
selector: 'app-root',
imports: [NgIcon],
templateUrl: './settings-window.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
},
})
export class SettingsWindow {
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly shortcuts = inject(ShortcutsService);
readonly isMacChrome = IS_MAC_CHROME;
readonly sections = SECTIONS;
readonly section = signal<Section>('appearance');
readonly localeOptions = SUPPORTED;
readonly localeLabels = LOCALE_LABELS;
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly accents = ACCENTS;
readonly speedUnits = SPEED_UNITS;
readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES;
readonly columnKeys = COLUMN_KEYS;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
/** "100%" for the stepper readout; the value itself lives in settings. */
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return this.i18n.t('options.systemDefault');
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
}
showAbout(): void {
void this.rpc.request.showAbout({});
}
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'settings');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
}