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
+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({