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:
@@ -10,12 +10,12 @@
|
||||
[class.pl-3]="!isMacChrome"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||
<h1 class="text-sm font-semibold">Connections</h1>
|
||||
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="importWinScp()"
|
||||
[disabled]="importing()"
|
||||
title="Import sessions from a WinSCP.ini export"
|
||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
@if (importing()) {
|
||||
@@ -23,7 +23,7 @@
|
||||
} @else {
|
||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
||||
}
|
||||
Import from WinSCP...
|
||||
{{ i18n.t('connMgr.importWinscp') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -33,17 +33,17 @@
|
||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
(click)="newConnection()"
|
||||
title="New connection"
|
||||
[title]="i18n.t('connMgr.newConnectionHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
|
||||
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="newStack()"
|
||||
title="New stack"
|
||||
[title]="i18n.t('connMgr.newStackHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> Stack
|
||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<button
|
||||
(click)="toggleStack(group.stack)"
|
||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
[title]="group.stack.collapsed ? 'Expand' : 'Collapse'"
|
||||
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
||||
@@ -85,14 +85,14 @@
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
(click)="renameStack(group.stack)"
|
||||
title="Rename stack"
|
||||
[title]="i18n.t('connMgr.renameStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteStack(group.stack)"
|
||||
title="Delete stack"
|
||||
[title]="i18n.t('connMgr.deleteStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
||||
@@ -100,7 +100,7 @@
|
||||
@if (group.connections.length) {
|
||||
<button
|
||||
(click)="openStack(group.stack.id)"
|
||||
title="Open stack in tabs"
|
||||
[title]="i18n.t('connMgr.openStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
||||
@@ -123,7 +123,7 @@
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">empty</div>
|
||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@
|
||||
>
|
||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
||||
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase">
|
||||
Connections
|
||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
||||
</div>
|
||||
}
|
||||
@for (connection of store.grouped().loose; track connection.id) {
|
||||
@@ -163,10 +163,10 @@
|
||||
|
||||
@if (!store.connections().length && !store.stacks().length) {
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
No saved connections yet.
|
||||
{{ i18n.t('connMgr.noneTitle') }}
|
||||
</p>
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Create one, or import your WinSCP sessions.
|
||||
{{ i18n.t('connMgr.noneBody') }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -177,18 +177,18 @@
|
||||
@if (draft(); as d) {
|
||||
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Name</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||
<input
|
||||
[value]="d.name"
|
||||
(input)="patch('name', $any($event.target).value)"
|
||||
placeholder="my server"
|
||||
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Host</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
|
||||
<input
|
||||
[value]="d.host"
|
||||
(input)="patch('host', $any($event.target).value)"
|
||||
@@ -199,7 +199,7 @@
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Port</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
[value]="d.port"
|
||||
@@ -208,14 +208,14 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Stack</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
||||
<select
|
||||
(change)="patch('stackId', $any($event.target).value || null)"
|
||||
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<!-- [selected] per option: binding [value] on the <select> runs
|
||||
before the options render and silently shows "(none)" -->
|
||||
<option value="" [selected]="!d.stackId">(none)</option>
|
||||
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
||||
@for (stack of store.stacks(); track stack.id) {
|
||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
||||
{{ stack.name }}
|
||||
@@ -226,7 +226,7 @@
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Username</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
|
||||
<input
|
||||
[value]="d.username"
|
||||
(input)="patch('username', $any($event.target).value)"
|
||||
@@ -235,18 +235,18 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Remote path (optional)</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
|
||||
<input
|
||||
[value]="d.initialPath"
|
||||
(input)="patch('initialPath', $any($event.target).value)"
|
||||
placeholder="home directory"
|
||||
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="col-span-2 flex items-center gap-4 text-xs">
|
||||
<span class="text-muted-foreground">Auth</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -255,7 +255,7 @@
|
||||
(change)="patch('authKind', 'password')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
Password
|
||||
{{ i18n.t('connMgr.authPassword') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
@@ -265,13 +265,13 @@
|
||||
(change)="patch('authKind', 'key')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
Private key
|
||||
{{ i18n.t('connMgr.authKey') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (d.authKind === 'password') {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Password</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.password"
|
||||
@@ -281,7 +281,7 @@
|
||||
</label>
|
||||
} @else {
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Private key file</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
[value]="d.privateKeyPath"
|
||||
@@ -295,12 +295,12 @@
|
||||
(click)="pickKeyFile()"
|
||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
|
||||
>
|
||||
Choose…
|
||||
{{ i18n.t('common.choose') }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Passphrase (optional)</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.passphrase"
|
||||
@@ -312,16 +312,16 @@
|
||||
|
||||
@if (moreOpen()) {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Local directory (optional)</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
||||
<input
|
||||
[value]="d.localDir"
|
||||
(input)="patch('localDir', $any($event.target).value)"
|
||||
placeholder="~/projects/my-server"
|
||||
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span class="text-[10px] text-muted-foreground/80">
|
||||
The other panel opens here when it's on Local and hasn't been navigated.
|
||||
{{ i18n.t('connMgr.localDirHint') }}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
@@ -332,13 +332,13 @@
|
||||
(click)="deleteCurrent()"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
|
||||
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="moreOpen.set(!moreOpen())"
|
||||
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ moreOpen() ? 'Less…' : 'More…' }}
|
||||
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
@@ -346,7 +346,7 @@
|
||||
[disabled]="!valid()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndConnect()"
|
||||
@@ -356,7 +356,7 @@
|
||||
@if (busy()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
}
|
||||
Connect
|
||||
{{ i18n.t('common.connect') }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -364,7 +364,7 @@
|
||||
<div class="text-center">
|
||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Select a connection or create a new one.
|
||||
{{ i18n.t('connMgr.selectPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user