Internationalization: 7 languages with live switching

Custom signal-based runtime (no i18n dependency): shared src/shared/i18n
dictionaries, ui I18nService.t() reactive by construction so language
switches live, and a backend i18n.ts for native menus + thrown errors.
Every user-facing string keyed across templates, toasts, dialogs, file
kinds, columns, macOS menu/context menus, and errors. Language picker in
Options (auto-detects OS locale, falls back to English); switching
relabels the webview and native chrome instantly. English/Spanish/French/
German fully drafted; Chinese/Japanese/Basque drafted, flagged for native
review. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:52:54 +02:00
co-authored by Claude Fable 5
parent d8cfc54049
commit a0f56822cd
43 changed files with 2755 additions and 322 deletions
+68 -45
View File
@@ -1,7 +1,9 @@
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 { I18nService } from '../i18n.service';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
import { RpcService } from '../rpc.service';
@@ -10,16 +12,17 @@ import { SettingsService } from '../settings.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).
const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)', label: 'Graphite' },
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)', label: 'Blue' },
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)', label: 'Indigo' },
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)', label: 'Purple' },
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)', label: 'Rose' },
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)', label: 'Orange' },
{ value: 'green', swatch: 'oklch(0.65 0.15 150)', label: 'Green' },
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)', label: 'Teal' },
// 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' },
@@ -78,7 +81,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@if (panels.workspaces().length > 1) {
<button
(click)="close($event, ws.id)"
[title]="'Close tab (' + modLabel + 'W)'"
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
@@ -94,7 +97,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div>
<button
(click)="panels.newWorkspace()"
[title]="'New tab (' + modLabel + 'T)'"
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
class="ml-0.5 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="mynaPlus" size=".8rem" />
@@ -105,17 +108,17 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<!-- connection manager (own native window) -->
<button
(click)="openManager()"
title="Manage connections"
[title]="i18n.t('tabBar.manageConnections')"
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
Connections
{{ i18n.t('tabBar.connections') }}
</button>
<!-- global options -->
<button
(click)="settingsOpen.set(!settingsOpen())"
title="Options"
[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="mynaDots" size="1.5rem" />
@@ -124,22 +127,39 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@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-64 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
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">Options</h3>
<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">Theme</span>
<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-[11px] 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-[11px] capitalize"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ theme }}
{{ i18n.t('options.theme.' + theme) }}
</button>
}
</div>
@@ -147,17 +167,17 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@if (settings.effectiveDark()) {
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Tone</span>
<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-[11px] capitalize"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ tone }}
{{ i18n.t('options.tone.' + tone) }}
</button>
}
</div>
@@ -165,12 +185,12 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Accent</span>
<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]="tone.label"
[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"
@@ -183,41 +203,41 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">File icons</span>
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of iconColorModes; track mode) {
<button
(click)="settings.setIconColors(mode)"
class="rounded px-2 py-0.5 text-[11px] capitalize"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.iconColors() === mode"
[class.text-foreground]="settings.iconColors() === mode"
[class.text-muted-foreground]="settings.iconColors() !== mode"
>
{{ mode }}
{{ i18n.t('options.fileIcons.' + mode) }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Interface</span>
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (size of textSizes; track size) {
<button
(click)="settings.setTextSize(size)"
class="rounded px-2 py-0.5 text-[11px] capitalize"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.textSize() === size"
[class.text-foreground]="settings.textSize() === size"
[class.text-muted-foreground]="settings.textSize() !== size"
>
{{ size }}
{{ i18n.t('options.interface.' + size) }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Speed unit</span>
<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
@@ -234,17 +254,17 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Transfers</span>
<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-[11px] capitalize"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode"
>
{{ mode }}
{{ i18n.t('options.transfers.' + mode) }}
</button>
}
</div>
@@ -252,7 +272,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@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">At once</span>
<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
@@ -270,7 +290,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Editor</span>
<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')"
@@ -279,7 +299,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
Built-in
{{ i18n.t('options.editor.builtin') }}
</button>
<button
(click)="settings.setEditor('custom')"
@@ -288,7 +308,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
Custom
{{ i18n.t('options.editor.custom') }}
</button>
</div>
</div>
@@ -303,12 +323,12 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
(click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
>
Choose…
{{ i18n.t('common.choose') }}
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
title="Reset to system default"
[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="mynaX" size="0.7rem" />
@@ -319,7 +339,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Finished transfers</span>
<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)"
@@ -328,7 +348,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
Auto-clear
{{ i18n.t('options.finishedTransfers.autoClear') }}
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
@@ -337,7 +357,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
Keep
{{ i18n.t('options.finishedTransfers.keep') }}
</button>
</div>
</div>
@@ -374,8 +394,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
export class TabBar {
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
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;
@@ -413,7 +436,7 @@ export class TabBar {
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return 'System default';
if (!path) return this.i18n.t('options.systemDefault');
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}