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
+4 -4
View File
@@ -3,9 +3,9 @@
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">Not connected to the app</p>
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
This page is running in a plain browser. Launch the desktop app to use it.
{{ i18n.t('app.notConnectedBody') }}
</p>
</div>
</div>
@@ -29,11 +29,11 @@
>
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">The connection manager is open.</p>
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Show Connections
{{ i18n.t('app.showConnections') }}
</button>
</div>
</div>
+7 -4
View File
@@ -1,5 +1,6 @@
import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from './i18n.service';
import { BRANDING } from '@shared/branding';
import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from './connections.service';
@@ -42,6 +43,7 @@ import { TransfersService } from './transfers.service';
},
})
export class App {
readonly i18n = inject(I18nService);
readonly rpc = inject(RpcService);
readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService);
@@ -76,11 +78,12 @@ export class App {
const res = await this.rpc.request.getEditLeftovers({});
if (!res.ok || !res.names.length) return;
const shown = res.names.slice(0, 5).join(', ');
const more = res.names.length > 5 ? `, +${res.names.length - 5} more` : '';
const more =
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
this.dialogs.confirm.set({
title: 'Leftover edited files',
body: `A previous session left ${res.names.length} temporary edited file(s) behind (${shown}${more}). Clear them?`,
confirmLabel: 'Clear',
title: this.i18n.t('toasts.leftoverTitle'),
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
confirmLabel: this.i18n.t('toasts.leftoverClear'),
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
@@ -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>
@@ -1,5 +1,6 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
@@ -79,6 +80,7 @@ function fromDraft(d: Draft): SavedConnection {
},
})
export class ConnectionManager {
readonly i18n = inject(I18nService);
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
readonly isMacChrome = IS_MAC_CHROME;
@@ -174,9 +176,9 @@ export class ConnectionManager {
return;
}
this.dialogs.confirm.set({
title: 'Delete connection',
body: `Delete "${d.name || d.host}"?`,
confirmLabel: 'Delete',
title: this.i18n.t('connMgr.deleteConnectionTitle'),
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: async () => {
await this.store.remove(d.id);
@@ -212,27 +214,27 @@ export class ConnectionManager {
newStack(): void {
this.dialogs.prompt.set({
title: 'New stack',
placeholder: 'stack name',
confirmLabel: 'Create',
title: this.i18n.t('connMgr.newStackTitle'),
placeholder: this.i18n.t('connMgr.newStackPrompt'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
});
}
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: 'Rename stack',
title: this.i18n.t('connMgr.renameStackTitle'),
initial: stack.name,
confirmLabel: 'Rename',
confirmLabel: this.i18n.t('connMgr.renameStack'),
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: 'Delete stack',
body: `Delete stack "${stack.name}"? Its connections stay, ungrouped.`,
confirmLabel: 'Delete',
title: this.i18n.t('connMgr.deleteStackTitle'),
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: () => void this.store.removeStack(stack.id),
});
+5 -4
View File
@@ -1,10 +1,12 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
import { RpcService } from './rpc.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
@Injectable({ providedIn: 'root' })
export class ConnectionsService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
@@ -86,11 +88,10 @@ export class ConnectionsService {
return;
}
if (res.connections || res.stacks || res.skipped) {
const skipped = res.skipped ? ` (${res.skipped} skipped)` : '';
const skipped = res.skipped ? this.i18n.t('toasts.importedSkipped', { skipped: res.skipped }) : '';
const stacks = res.stacks ? this.i18n.t('toasts.importedIntoStacks', { count: res.stacks }) : '';
this.toast.success(
`Imported ${res.connections} connection${res.connections === 1 ? '' : 's'}` +
(res.stacks ? ` into ${res.stacks} new stack${res.stacks === 1 ? '' : 's'}` : '') +
skipped,
this.i18n.t('toasts.imported', { count: res.connections }) + stacks + skipped,
);
}
await this.reload();
+14 -10
View File
@@ -1,8 +1,9 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type ChmodRequest } from '../dialogs.service';
import { FocusService } from '../focus.service';
const ROLES = ['Owner', 'Group', 'Others'] as const;
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
const PERMS = ['r', 'w', 'x'] as const;
@Component({
@@ -14,22 +15,22 @@ const PERMS = ['r', 'w', 'x'] as const;
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">Permissions</h2>
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
<table class="mt-3 w-full text-xs">
<thead>
<tr class="text-muted-foreground">
<th class="pb-1 text-left font-normal"></th>
<th class="pb-1 font-normal">Read</th>
<th class="pb-1 font-normal">Write</th>
<th class="pb-1 font-normal">Execute</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
</tr>
</thead>
<tbody>
@for (role of roles; track role; let r = $index) {
<tr>
<td class="py-1 pr-2 text-muted-foreground">{{ role }}</td>
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
@for (perm of perms; track perm; let p = $index) {
<td class="py-1 text-center">
<input
@@ -46,7 +47,7 @@ const PERMS = ['r', 'w', 'x'] as const;
</table>
<div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">Octal</label>
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input
[value]="octal()"
(input)="setOctal($any($event.target).value)"
@@ -61,13 +62,13 @@ const PERMS = ['r', 'w', 'x'] as const;
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Apply
{{ i18n.t('common.apply') }}
</button>
</div>
</div>
@@ -75,6 +76,7 @@ const PERMS = ['r', 'w', 'x'] as const;
`,
})
export class ChmodDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ChmodRequest>();
readonly roles = ROLES;
@@ -90,7 +92,9 @@ export class ChmodDialog {
targetLabel(): string {
const targets = this.request().targets;
return targets.length === 1 ? targets[0] : `${targets.length} items`;
return targets.length === 1
? targets[0]
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
}
hasBit(role: number, perm: number): boolean {
+3 -1
View File
@@ -1,4 +1,5 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type ConfirmRequest } from '../dialogs.service';
import { FocusService } from '../focus.service';
@@ -18,7 +19,7 @@ import { FocusService } from '../focus.service';
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<button
#confirmBtn
@@ -37,6 +38,7 @@ import { FocusService } from '../focus.service';
`,
})
export class ConfirmDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ConfirmRequest>();
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
+17 -19
View File
@@ -8,8 +8,8 @@ import {
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
import { formatDate, formatSize } from '../format';
import { FocusService } from '../focus.service';
@Component({
@@ -25,23 +25,22 @@ import { FocusService } from '../focus.service';
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? 'Merge folder' : 'Replace' }}
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{{ request().index }} of {{ request().total }}
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
The destination already has a folder with this name. Merging may replace files inside
it.
{{ i18n.t('dialogs.overwriteDirBody') }}
} @else {
The destination already has a file with this name.
{{ i18n.t('dialogs.overwriteFileBody') }}
}
</p>
@@ -51,11 +50,11 @@ import { FocusService } from '../focus.service';
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{{ side.label }}
{{ i18n.t('dialogs.' + side.label) }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
newer
{{ i18n.t('dialogs.newer') }}
</span>
}
</div>
@@ -66,11 +65,11 @@ import { FocusService } from '../focus.service';
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : formatSize(side.entry.size) }}
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
{{ formatDate(side.entry.mtime) }}
{{ i18n.date(side.entry.mtime) }}
</div>
</div>
}
@@ -82,7 +81,7 @@ import { FocusService } from '../focus.service';
(click)="pick('cancel')"
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<div class="flex-1"></div>
@if (request().total > 1) {
@@ -90,13 +89,13 @@ import { FocusService } from '../focus.service';
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
Skip all
{{ i18n.t('dialogs.skipAll') }}
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ isDir() ? 'Merge all' : 'Replace all' }}
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button>
<div class="h-4 w-px bg-border"></div>
}
@@ -104,14 +103,14 @@ import { FocusService } from '../focus.service';
(click)="pick('skip')"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Skip
{{ i18n.t('dialogs.skip') }}
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ isDir() ? 'Merge' : 'Replace' }}
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button>
</div>
</div>
@@ -119,10 +118,9 @@ import { FocusService } from '../focus.service';
`,
})
export class OverwriteDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<OverwriteRequest>();
readonly formatSize = formatSize;
readonly formatDate = formatDate;
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
@@ -137,8 +135,8 @@ export class OverwriteDialog {
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'Source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'Destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
+3 -1
View File
@@ -7,6 +7,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type PromptRequest } from '../dialogs.service';
import { FocusService } from '../focus.service';
@@ -34,7 +35,7 @@ import { FocusService } from '../focus.service';
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
@@ -49,6 +50,7 @@ import { FocusService } from '../focus.service';
`,
})
export class PromptDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<PromptRequest>();
readonly value = signal('');
+21 -22
View File
@@ -3,9 +3,9 @@
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">No file to edit</p>
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
This window was opened without an editor context. Launch files from the app.
{{ i18n.t('editor.noFileBody') }}
</p>
</div>
</div>
@@ -28,17 +28,17 @@
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
[title]="'Wrap long lines — hides line numbers (' + altLabel + 'Z)'"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()"
>
Wrap
{{ i18n.t('editor.wrap') }}
</button>
<button
(click)="openFind()"
[title]="'Find (' + modLabel + 'F)'"
[title]="i18n.t('editor.findHint', { mod: modLabel })"
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="mynaSearch" size="0.85rem" />
@@ -53,7 +53,7 @@
[value]="findQuery()"
(input)="onFindInput($any($event.target).value)"
(keydown)="onFindKeydown($event)"
placeholder="Find in file"
[placeholder]="i18n.t('editor.findPlaceholder')"
spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/>
@@ -66,14 +66,14 @@
</span>
<button
(click)="findStep(-1)"
title="Previous match (⇧↵)"
[title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronUp" size="0.8rem" />
</button>
<button
(click)="findStep(1)"
title="Next match (↵)"
[title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronDown" size="0.8rem" />
@@ -81,7 +81,7 @@
<div class="flex-1"></div>
<button
(click)="closeFind()"
title="Close (Esc)"
[title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
@@ -94,8 +94,7 @@
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
>
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
Disconnected from {{ ctx.sessionTitle }} — saving will fail, but your text is preserved
in this window.
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div>
}
@@ -107,13 +106,13 @@
<div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">Can't open {{ ctx.name }}</p>
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button
(click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Close Window
{{ i18n.t('editor.closeWindow') }}
</button>
</div>
</div>
@@ -142,17 +141,17 @@
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
<span class="text-[11px] text-muted-foreground">{{ modLabel }}S save · {{ modLabel }}F find · {{ modLabel }}W close</span>
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[11px] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground">
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
{{ i18n.t('editor.lines', { count: lineCount() }) }}
</span>
<div class="flex-1"></div>
<button
(click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Close
{{ i18n.t('editor.close') }}
</button>
<button
(click)="save()"
@@ -162,7 +161,7 @@
@if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
Save
{{ i18n.t('common.save') }}
</button>
</footer>
}
@@ -175,30 +174,30 @@
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">Save changes to "{{ ctx.name }}"?</h2>
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
<p class="mt-1.5 text-xs text-muted-foreground">
Your changes will be lost if you don't save them.
{{ i18n.t('editor.saveChangesBody') }}
</p>
<div class="mt-4 flex items-center gap-2">
<button
(click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
>
Don't Save
{{ i18n.t('editor.dontSave') }}
</button>
<div class="flex-1"></div>
<button
(click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="saveAndClose()"
[disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Save
{{ i18n.t('common.save') }}
</button>
</div>
</div>
+3 -1
View File
@@ -10,6 +10,7 @@ import {
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
@@ -38,6 +39,7 @@ const DRAFT_DEBOUNCE_MS = 400;
},
})
export class EditorWindow {
readonly i18n = inject(I18nService);
readonly ctx = EDITOR_CONTEXT;
readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
@@ -273,7 +275,7 @@ export class EditorWindow {
}
this.savedContent.set(content);
this.lastSentDirty = false; // bun record already cleaned by the handler
this.toast.success(`Saved ${this.ctx!.name}`);
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
return true;
} finally {
this.saving.set(false);
+18 -14
View File
@@ -6,6 +6,7 @@ import { type PanelState, type Side } from './panels.service';
import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
@@ -21,6 +22,7 @@ export interface MenuTarget {
*/
@Injectable({ providedIn: 'root' })
export class FileActionsService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
@@ -44,7 +46,7 @@ export class FileActionsService {
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) this.toast.error(res.error);
else this.toast.info(`Moved "${entry.name}" to Trash`);
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
await panel.refresh();
}
@@ -58,9 +60,9 @@ export class FileActionsService {
mkdir(panel: PanelState): void {
this.dialogs.prompt.set({
title: 'New folder',
placeholder: 'folder name',
confirmLabel: 'Create',
title: this.i18n.t('fileActions.newFolderTitle'),
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.mkdir({
sessionId: panel.sessionId(),
@@ -74,9 +76,9 @@ export class FileActionsService {
createFile(panel: PanelState): void {
this.dialogs.prompt.set({
title: 'New file',
placeholder: 'file name',
confirmLabel: 'Create',
title: this.i18n.t('fileActions.newFileTitle'),
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.createFile({
sessionId: panel.sessionId(),
@@ -101,7 +103,7 @@ export class FileActionsService {
if (!res.ok) {
this.toast.error(res.error);
} else if (res.watching) {
this.toast.info(`Editing ${entry.name} — saves upload automatically`);
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
}
return;
}
@@ -120,9 +122,9 @@ export class FileActionsService {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.name === '..') return;
this.dialogs.prompt.set({
title: `Rename "${entry.name}"`,
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
initial: entry.name,
confirmLabel: 'Rename',
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
onConfirm: async (name) => {
if (name === entry.name) return;
const res = await this.rpc.request.rename({
@@ -140,11 +142,13 @@ export class FileActionsService {
const targets = this.targets(panel);
if (!targets.length) return;
const what =
targets.length === 1 ? `"${targets[0].name}"` : `${targets.length} items`;
targets.length === 1
? `"${targets[0].name}"`
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
this.dialogs.confirm.set({
title: 'Delete',
body: `Delete ${what}? Folders are deleted recursively. This cannot be undone.`,
confirmLabel: 'Delete',
title: this.i18n.t('fileActions.deleteTitle'),
body: this.i18n.t('fileActions.deleteBody', { what }),
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
destructive: true,
onConfirm: async () => {
const res = await this.rpc.request.deleteEntries({
+15 -12
View File
@@ -64,22 +64,25 @@ function categoryOf(name: string): Category | null {
return EXT_CATEGORY.get(key) ?? null;
}
const KIND_LABELS: Record<Category, string> = {
image: 'Image',
audio: 'Audio',
video: 'Video',
archive: 'Archive',
code: 'Code',
doc: 'Document',
const KIND_KEYS: Record<Category, string> = {
image: 'image',
audio: 'audio',
video: 'video',
archive: 'archive',
code: 'code',
doc: 'document',
};
/** Human label for the optional Kind column (category-based, like the icons). */
export function fileKind(entry: FileEntry): string {
/**
* i18n key suffix for the optional Kind column (category-based, like the
* icons); '' for the synthetic '..' row. The caller resolves `kinds.<suffix>`.
*/
export function fileKindKey(entry: FileEntry): string {
if (entry.name === '..') return '';
if (entry.type === 'dir') return 'Folder';
if (entry.type === 'symlink') return 'Symlink';
if (entry.type === 'dir') return 'folder';
if (entry.type === 'symlink') return 'symlink';
const category = categoryOf(entry.name);
return category ? KIND_LABELS[category] : 'File';
return category ? KIND_KEYS[category] : 'file';
}
export function fileGlyph(entry: FileEntry, multi: boolean): string {
+26 -24
View File
@@ -3,7 +3,7 @@
<button
(click)="toggleMenu()"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
title="Choose what this panel shows"
[title]="i18n.t('panel.choosePanel')"
>
@if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
@@ -14,7 +14,7 @@
size="var(--action-icon)"
/>
}
<span class="truncate">{{ panel().title() }}</span>
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon
name="mynaChevronDown"
class="shrink-0 text-muted-foreground"
@@ -27,38 +27,38 @@
labels when the panel is wide enough (container query). -->
<button
(click)="copyToOther()"
[title]="side() === 'left' ? 'Copy to right panel (F5)' : 'Copy to left panel (F5)'"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
>
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Copy</span>
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
</button>
<button
(click)="run('edit')"
title="Edit file (F4)"
[title]="i18n.t('panel.editFile')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFileText" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Edit</span>
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button>
<button
(click)="run('rename')"
title="Rename (F2)"
[title]="i18n.t('panel.rename')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaEdit" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Rename</span>
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button>
<button
(click)="run('mkdir')"
title="New folder (F7)"
[title]="i18n.t('panel.newFolder')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
</button>
<button
(click)="run('createFile')"
title="New file"
[title]="i18n.t('panel.newFile')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
@@ -66,7 +66,7 @@
@if (chmodAvailable()) {
<button
(click)="run('chmod')"
title="Permissions"
[title]="i18n.t('panel.permissions')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="var(--action-icon)" />
@@ -74,11 +74,11 @@
}
<button
(click)="run('delete')"
title="Delete (F8 / Del)"
[title]="i18n.t('panel.deleteHint')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Delete</span>
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button>
@if (menuOpen()) {
@@ -91,7 +91,7 @@
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">Local</span>
<span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
}
@@ -162,7 +162,7 @@
<button
(click)="panel().up()"
[disabled]="blocked()"
title="Parent directory (Backspace)"
[title]="i18n.t('panel.parentDir')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
<ng-icon name="mynaArrowUp" size="1rem" />
@@ -180,7 +180,7 @@
<button
(click)="panel().refresh()"
[disabled]="blocked()"
title="Refresh"
[title]="i18n.t('panel.refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
@if (panel().loading()) {
@@ -198,7 +198,7 @@
class="grid border-b border-border text-[11px] font-medium text-muted-foreground"
>
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
Name {{ sortIndicator('name') }}
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
</button>
@for (col of visibleColumns(); track col.key) {
<button
@@ -207,7 +207,7 @@
[class.text-left]="col.key !== 'size'"
(click)="panel().toggleSort(col.key)"
>
{{ col.label }} {{ sortIndicator(col.key) }}
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
</button>
}
</div>
@@ -218,11 +218,11 @@
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[11px] text-muted-foreground">
{{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
</span>
<button
(click)="clearFilter()"
title="Clear filter (Esc)"
[title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
@@ -245,13 +245,13 @@
<div class="flex flex-col items-center gap-3">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground">
Connecting to {{ panel().connectingTo() }}…
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span>
<button
(click)="cancelConnect()"
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
</div>
</div>
@@ -290,6 +290,8 @@
<span class="truncate px-2 text-(length:--list-meta-font) text-muted-foreground">
{{ fileKind(entry) }}
</span>
<!-- fileKind is localized in file-panel.ts via i18n -->
}
@case ('rights') {
<span class="px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
@@ -310,10 +312,10 @@
}
</div>
} @empty {
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
<div class="p-6 text-center text-xs text-muted-foreground">{{ i18n.t('panel.emptyDir') }}</div>
}
@if (panel().filter() && filterMatches() === 0) {
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
<div class="p-6 text-center text-xs text-muted-foreground">{{ i18n.t('panel.noMatches') }}</div>
}
}
</div>
+12 -5
View File
@@ -9,12 +9,13 @@ import {
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { FileActionsService } from '../file-actions.service';
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
import { formatDate, formatMode, formatSize } from '../format';
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons';
import { formatMode } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service';
import { IS_WINDOWS } from '../platform';
import { RpcService } from '../rpc.service';
@@ -38,6 +39,7 @@ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
})
export class FilePanel {
readonly i18n = inject(I18nService);
readonly side = input.required<Side>();
readonly panels = inject(PanelsService);
@@ -65,10 +67,15 @@ export class FilePanel {
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private lastClickedIndex = 0;
readonly formatSize = formatSize;
readonly formatDate = formatDate;
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
// cells re-render on a language switch (see i18n.service reactive t())
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
readonly formatDate = (ms: number): string => this.i18n.date(ms);
readonly formatMode = formatMode;
readonly fileKind = fileKind;
readonly fileKind = (entry: FileEntry): string => {
const key = fileKindKey(entry);
return key ? this.i18n.t('kinds.' + key) : '';
};
/** The optional columns currently shown, in canonical order. */
readonly visibleColumns = computed(() => {
+32 -15
View File
@@ -1,34 +1,51 @@
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
export function formatSize(bytes: number): string {
/** Locale-aware number formatting (decimal separator); cached per locale. */
const NUM_FORMATS = new Map<string, Intl.NumberFormat>();
function num(value: number, locale: string | undefined): string {
const key = locale ?? '';
let fmt = NUM_FORMATS.get(key);
if (!fmt) {
fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
NUM_FORMATS.set(key, fmt);
}
return fmt.format(value);
}
export function formatSize(bytes: number, locale?: string): string {
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`;
return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
}
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
export function formatDate(epochMs: number): string {
const DATE_FORMATS = new Map<string, Intl.DateTimeFormat>();
export function formatDate(epochMs: number, locale?: string): string {
if (!epochMs) return '';
return DATE_FORMAT.format(new Date(epochMs));
const key = locale ?? '';
let fmt = DATE_FORMATS.get(key);
if (!fmt) {
fmt = new Intl.DateTimeFormat(locale, {
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
DATE_FORMATS.set(key, fmt);
}
return fmt.format(new Date(epochMs));
}
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string {
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
if (unit === 'Mbps') {
return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`;
return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
}
return `${formatSize(bytesPerSec)}/s`;
return `${formatSize(bytesPerSec, locale)}/s`;
}
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
+61
View File
@@ -0,0 +1,61 @@
import { Injectable, computed, signal } from '@angular/core';
import { DICTS, type Messages } from '@shared/i18n';
import type { AppSettings, SupportedLocale } from '@shared/types';
import { formatDate, formatSize, formatSpeed } from './format';
type Params = Record<string, string | number>;
function lookup(dict: Messages, key: string): string | Messages | undefined {
let node: string | Messages | undefined = dict;
for (const part of key.split('.')) {
if (typeof node !== 'object') return undefined;
node = node[part];
}
return node;
}
function interpolate(template: string, params?: Params): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
}
/**
* Runtime translator. `t()` reads the `locale` signal, so any template binding
* that calls it re-renders live on a language switch (the same reactive
* function-call tracking the file-list icon classes rely on). Missing keys fall
* back to English, then to the raw key so gaps are visible in dev.
*/
@Injectable({ providedIn: 'root' })
export class I18nService {
readonly locale = signal<SupportedLocale>('en');
private readonly dict = computed(() => DICTS[this.locale()]);
t(key: string, params?: Params): string {
let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key);
// plural map: pick the CLDR category for params.count (fallback to English)
if (value && typeof value === 'object') {
const count = Number(params?.['count'] ?? NaN);
if (Number.isFinite(count)) {
const rule = new Intl.PluralRules(this.locale()).select(count);
const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`);
value = typeof picked === 'string' ? picked : undefined;
} else {
value = undefined;
}
}
return typeof value === 'string' ? interpolate(value, params) : key;
}
// Locale-reactive formatting (live-updates the file list on switch).
date(epochMs: number): string {
return formatDate(epochMs, this.locale());
}
size(bytes: number): string {
return formatSize(bytes, this.locale());
}
speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string {
return formatSpeed(bytesPerSec, unit, this.locale());
}
}
+11 -4
View File
@@ -1,9 +1,10 @@
import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { fileKind } from './file-icons';
import { fileKindKey } from './file-icons';
import { FocusService } from './focus.service';
import { formatMode, isRootPath, joinPath } from './format';
import { RpcService } from './rpc.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
export type Side = 'left' | 'right';
@@ -12,7 +13,7 @@ export type SortKey = 'name' | ColumnKey;
/** Display string the text-based columns sort by. */
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
if (key === 'kind') return fileKind(entry);
if (key === 'kind') return fileKindKey(entry);
if (key === 'rights') return formatMode(entry.mode);
return (key === 'owner' ? entry.owner : entry.group) ?? '';
}
@@ -225,6 +226,7 @@ export interface Workspace {
@Injectable({ providedIn: 'root' })
export class PanelsService {
private readonly i18n = inject(I18nService);
readonly workspaces = signal<Workspace[]>([]);
readonly activeWorkspaceId = signal(0);
readonly activeWorkspace = computed(() => {
@@ -248,7 +250,12 @@ export class PanelsService {
for (const workspace of this.workspaces()) {
for (const panel of [workspace.left, workspace.right]) {
if (panel.sessionId() === event.sessionId) {
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
this.toast.error(
this.i18n.t('toasts.disconnectedReason', {
title: panel.title(),
reason: event.reason,
}),
);
this.resetToLocal(panel);
}
}
@@ -453,7 +460,7 @@ export class PanelsService {
const remoteTitles = [workspace.left, workspace.right]
.filter((p) => p.sessionId() !== 'local')
.map((p) => p.title());
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local';
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local');
}
workspaceIsRemote(workspace: Workspace): boolean {
+17
View File
@@ -1,9 +1,12 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { resolveLocale } from '@shared/i18n';
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
import { I18nService } from './i18n.service';
import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' })
export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
@@ -23,6 +26,7 @@ export class SettingsService {
// gate the poll/watch in panels.service on it, and add the tab-bar row below.
private readonly rpc = inject(RpcService);
private readonly i18n = inject(I18nService);
/** Whether the app is actually rendering dark right now (drives the Tone row). */
readonly effectiveDark = computed(
@@ -45,6 +49,7 @@ export class SettingsService {
}
private applyAll(settings: AppSettings): void {
this.locale.set(settings.locale);
this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone);
this.accent.set(settings.accent);
@@ -61,6 +66,13 @@ export class SettingsService {
this.applyDarkTone();
this.applyAccent();
this.applyTextSize();
this.applyLocale();
}
setLocale(locale: AppSettings['locale']): void {
this.locale.set(locale);
this.applyLocale();
void this.persist();
}
setTheme(theme: AppSettings['theme']): void {
@@ -166,8 +178,13 @@ export class SettingsService {
}
}
private applyLocale(): void {
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
}
protected settings(): AppSettings {
return {
locale: this.locale(),
theme: this.theme(),
darkTone: this.darkTone(),
accent: this.accent(),
+13 -9
View File
@@ -1,5 +1,6 @@
import { Component, computed, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service';
import type { TransferSnapshot } from '@shared/types';
import { formatSpeed } from '../format';
import { PanelsService } from '../panels.service';
@@ -17,26 +18,26 @@ import { TransfersService } from '../transfers.service';
<button
(click)="transfers.expanded.set(!transfers.expanded())"
class="flex min-w-0 items-center gap-1.5 hover:text-foreground"
title="Show transfer details"
[title]="i18n.t('transfer.showDetails')"
>
@if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate">
Copying {{ job.name }} · {{ percent(job) }}%{{ speedSuffix(job) }}
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
@if (transfers.queuedCount(); as queued) {
· {{ queued }} queued
· {{ i18n.t('transfer.queued', { count: queued }) }}
}
</span>
} @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
<span class="text-destructive">
{{ failed }} transfer{{ failed === 1 ? '' : 's' }} failed
{{ i18n.t('transfer.failed', { count: failed }) }}
</span>
} @else if (transfers.hasJobs()) {
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
<span>Transfers finished</span>
<span>{{ i18n.t('transfer.finished') }}</span>
} @else {
<span>Ready</span>
<span>{{ i18n.t('transfer.ready') }}</span>
}
</button>
@@ -47,16 +48,19 @@ import { TransfersService } from '../transfers.service';
size="0.8rem"
[class.text-primary]="activePanel().sessionId() !== 'local'"
/>
<span class="max-w-56 truncate">{{ activePanel().title() }}</span>
<span>· {{ activePanel().sorted().length }} items</span>
<span class="max-w-56 truncate">
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
</span>
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
@if (activePanel().selected().size; as count) {
<span>· {{ count }} selected</span>
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
}
</div>
</div>
`,
})
export class StatusBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
+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, '');
}
+4 -4
View File
@@ -1,14 +1,14 @@
@if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border px-3 py-1 text-[11px] text-muted-foreground">
<span>
{{ transfers.activeJobs().length }} active · {{ transfers.finishedJobs().length }} finished
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
</span>
<button
(click)="transfers.clearFinished()"
[disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
<ng-icon name="mynaTrash" size="0.75rem" /> Clear finished
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
</button>
</div>
<div class="max-h-40 overflow-y-auto border-t border-border">
@@ -30,7 +30,7 @@
@if (isActive(job)) {
<button
(click)="transfers.cancel(job.id)"
title="Cancel"
[title]="i18n.t('transfer.cancel')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaX" size="0.9rem" />
@@ -38,7 +38,7 @@
} @else {
<button
(click)="transfers.dismiss(job.id)"
title="Dismiss"
[title]="i18n.t('transfer.dismiss')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
+17 -10
View File
@@ -1,7 +1,8 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types';
import { formatEta, formatSize, formatSpeed } from '../format';
import { formatEta } from '../format';
import { I18nService } from '../i18n.service';
import { SettingsService } from '../settings.service';
import { TransfersService } from '../transfers.service';
@@ -11,18 +12,21 @@ import { TransfersService } from '../transfers.service';
templateUrl: './transfer-bar.html',
})
export class TransferBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly settings = inject(SettingsService);
readonly formatSize = formatSize;
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
speedLabel(job: TransferSnapshot): string {
if (job.state !== 'running') return '';
const speed = this.transfers.speeds().get(job.id);
if (!speed) return '';
const parts = [formatSpeed(speed, this.settings.speedUnit())];
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
const remaining = job.totalBytes - job.doneBytes;
if (remaining > 0 && speed > 0) parts.push(`${formatEta(remaining / speed)} left`);
if (remaining > 0 && speed > 0) {
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
}
return parts.join(' · ');
}
@@ -61,15 +65,18 @@ export class TransferBar {
}
progressLabel(job: TransferSnapshot): string {
if (job.state === 'queued') return 'Queued';
if (job.state === 'scanning') return 'Scanning…';
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
if (job.state === 'failed')
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? 'Failed'}`;
if (job.state === 'cancelled') return 'Cancelled';
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
if (job.state === 'completed')
return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`;
return this.i18n.t('transfer.completedFiles', {
count: job.doneFiles,
size: this.i18n.size(job.doneBytes),
});
const speed = this.speedLabel(job);
const base = `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`;
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
return speed ? `${base} · ${speed}` : base;
}
}
+13 -6
View File
@@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service';
import { PanelsService, type Side } from './panels.service';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000;
@@ -13,6 +14,7 @@ const SPEED_SMOOTHING = 0.7;
@Injectable({ providedIn: 'root' })
export class TransfersService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly panels = inject(PanelsService);
private readonly toast = inject(ToastService);
@@ -53,19 +55,19 @@ export class TransfersService {
(e) => e.type !== 'symlink' && e.name !== '..',
);
if (!targets.length) {
this.toast.info('Nothing to copy (symlinks are skipped)');
this.toast.info(this.i18n.t('toasts.nothingToCopy'));
return;
}
const sameFs = src.fsKey() === dst.fsKey();
if (sameFs && src.cwd() === dst.cwd()) {
this.toast.info('Both panels show the same folder');
this.toast.info(this.i18n.t('toasts.sameFolder'));
return;
}
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
if (finalTargets === null) return; // user cancelled
if (!finalTargets.length) {
this.toast.info('Nothing to copy — all conflicting items were skipped');
this.toast.info(this.i18n.t('toasts.nothingToCopyAllSkipped'));
return;
}
@@ -105,11 +107,11 @@ export class TransfersService {
}
if (sameFs && dstEntry.path === target.path) {
// replacing a file with ITSELF would only destroy it — never offer that
this.toast.info(`Skipped "${target.name}" — it is the same file`);
this.toast.info(this.i18n.t('toasts.sameFile', { name: target.name }));
continue;
}
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
this.toast.info(this.i18n.t('toasts.typeMismatch', { name: target.name }));
continue;
}
if (mode === 'all') {
@@ -178,7 +180,12 @@ export class TransfersService {
}
} else if (snapshot.state === 'failed') {
this.expanded.set(true);
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`);
this.toast.error(
this.i18n.t('toasts.transferFailed', {
name: snapshot.name,
error: snapshot.error ?? this.i18n.t('toasts.unknownError'),
}),
);
this.refreshPanelsOn(snapshot.dstSessionId);
} else if (snapshot.state === 'cancelled') {
this.refreshPanelsOn(snapshot.dstSessionId);