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:
@@ -1,38 +1,12 @@
|
||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { Component, ElementRef, effect, inject, signal, viewChild } 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 { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
|
||||
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
|
||||
// cards (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;
|
||||
|
||||
@Component({
|
||||
selector: 'app-tab-bar',
|
||||
imports: [NgIcon],
|
||||
@@ -154,302 +128,25 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
{{ i18n.t('tabBar.connections') }}
|
||||
</button>
|
||||
|
||||
<!-- global options -->
|
||||
<!-- settings (own native window) -->
|
||||
<button
|
||||
(click)="settingsOpen.set(!settingsOpen())"
|
||||
(click)="openSettings()"
|
||||
[title]="i18n.t('tabBar.options')"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignEllipsis" size="0.8rem" />
|
||||
<ng-icon name="tdesignSetting" size="0.9rem" />
|
||||
</button>
|
||||
|
||||
@if (settingsOpen()) {
|
||||
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
|
||||
<div
|
||||
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
||||
>
|
||||
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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-card]="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), which is also why this popover itself grows with it. -->
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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="mt-2.5 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="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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="mt-2.5 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>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ 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>
|
||||
|
||||
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
|
||||
the breadcrumbs in types.ts + settings.service, then this row:
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.autoRefresh()"
|
||||
[class.text-foreground]="settings.autoRefresh()"
|
||||
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
||||
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="!settings.autoRefresh()"
|
||||
[class.text-foreground]="!settings.autoRefresh()"
|
||||
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- about footer -->
|
||||
<button
|
||||
(click)="showAbout()"
|
||||
class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{{ appName }} · {{ buildLabel }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TabBar {
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly updater = inject(UpdaterService);
|
||||
private readonly contextMenu = inject(ContextMenuService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly localeOptions = SUPPORTED;
|
||||
readonly localeLabels = LOCALE_LABELS;
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
readonly themes = THEMES;
|
||||
readonly darkTones = DARK_TONES;
|
||||
readonly accents = ACCENTS;
|
||||
readonly speedUnits = SPEED_UNITS;
|
||||
/** "100%" for the stepper readout; the value itself lives in settings. */
|
||||
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
||||
readonly transferModes = TRANSFER_MODES;
|
||||
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||
|
||||
// drag & drop reordering
|
||||
readonly draggedId = signal<number | null>(null);
|
||||
@@ -506,18 +203,14 @@ export class TabBar {
|
||||
);
|
||||
}
|
||||
|
||||
/** "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, '');
|
||||
}
|
||||
|
||||
openManager(): void {
|
||||
void this.rpc.request.openConnectionManager({});
|
||||
}
|
||||
|
||||
openSettings(): void {
|
||||
void this.rpc.request.openSettings({});
|
||||
}
|
||||
|
||||
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
||||
onUpdateClick(): void {
|
||||
const state = this.updater.state();
|
||||
@@ -525,15 +218,6 @@ export class TabBar {
|
||||
else if (state === 'ready') this.updater.apply();
|
||||
}
|
||||
|
||||
showAbout(): void {
|
||||
void this.rpc.request.showAbout({});
|
||||
}
|
||||
|
||||
async pickEditorApp(): Promise<void> {
|
||||
const res = await this.rpc.request.pickEditorApp({});
|
||||
if (res.ok && res.path) this.settings.setEditorApp(res.path);
|
||||
}
|
||||
|
||||
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
||||
dropIndicatorShadow(index: number): string | null {
|
||||
const target = this.dropTarget();
|
||||
|
||||
Reference in New Issue
Block a user