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
+29 -35
View File
@@ -1,25 +1,21 @@
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
import type { ColumnKey } from "../shared/types";
import { t } from "./i18n";
/** Optional list columns, in display order (Name is fixed). */
const COLUMNS: { key: ColumnKey; label: string }[] = [
{ key: "size", label: "Size" },
{ key: "mtime", label: "Modified" },
{ key: "kind", label: "Kind" },
{ key: "rights", label: "Rights" },
{ key: "owner", label: "Owner" },
{ key: "group", label: "Group" },
];
const COLUMN_KEYS: ColumnKey[] = ["size", "mtime", "kind", "rights", "owner", "group"];
// PORTABILITY: shell-integration wording per OS
// PORTABILITY: shell-integration wording per OS (resolved at show() for locale)
const IS_WIN = process.platform === "win32";
const REVEAL_LABEL =
process.platform === "darwin"
? "Reveal in Finder"
: IS_WIN
? "Show in Explorer"
: "Show in File Manager";
const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash";
const revealLabel = (): string =>
t(
process.platform === "darwin"
? "ctx.revealFinder"
: IS_WIN
? "ctx.revealExplorer"
: "ctx.revealFileManager",
);
const trashLabel = (): string => t(IS_WIN ? "ctx.moveToRecycleBin" : "ctx.moveToTrash");
export interface RowMenuContext {
path: string;
@@ -75,8 +71,8 @@ export class PanelContextMenu {
/** Column picker for the list headers: checkable toggles per column. */
showColumns(visible: ColumnKey[]): void {
ContextMenu.showContextMenu(
COLUMNS.map(({ key, label }) => ({
label,
COLUMN_KEYS.map((key) => ({
label: t(`columns.${key}`),
action: `col:${key}`,
checked: visible.includes(key),
})),
@@ -88,47 +84,45 @@ export class PanelContextMenu {
if (!ctx.hasEntry || ctx.name === "..") {
// empty space (or the parent row): folder-level actions
items.push({ label: "New Folder", action: "ctx-new-folder" });
items.push({ label: "New File", action: "ctx-new-file" });
items.push({ label: t("ctx.newFolder"), action: "ctx-new-folder" });
items.push({ label: t("ctx.newFile"), action: "ctx-new-file" });
items.push({ type: "separator" });
items.push({ label: "Refresh", action: "ctx-refresh" });
items.push({ label: t("ctx.refresh"), action: "ctx-refresh" });
if (ctx.isLocal) {
items.push({ type: "separator" });
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
items.push({ label: revealLabel(), action: "ctx-reveal" });
}
return items;
}
if (ctx.type === "dir") {
items.push({ label: "Open", action: "ctx-open" });
items.push({ label: t("ctx.open"), action: "ctx-open" });
} else {
if (ctx.isLocal) {
items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" });
items.push({ label: t("ctx.open"), action: "ctx-open-default", tooltip: t("ctx.openDefault") });
}
items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" });
items.push({ label: t("ctx.edit"), action: "ctx-edit", accelerator: "e" });
}
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
items.push({ label: "Rename", action: "ctx-rename" });
items.push({ label: t("ctx.copyToOther"), action: "ctx-copy" });
items.push({ label: t("ctx.rename"), action: "ctx-rename" });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WIN)) {
items.push({ label: "Permissions", action: "ctx-chmod" });
items.push({ label: t("ctx.permissions"), action: "ctx-chmod" });
}
items.push({ type: "separator" });
items.push({ label: "Copy Path", action: "ctx-copy-path" });
items.push({ label: t("ctx.copyPath"), action: "ctx-copy-path" });
if (ctx.isLocal) {
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
items.push({ label: revealLabel(), action: "ctx-reveal" });
}
items.push({ type: "separator" });
if (ctx.isLocal) {
items.push({
label: TRASH_LABEL,
label: trashLabel(),
action: "ctx-trash",
...(process.platform === "darwin"
? { tooltip: "Finder's Restore is not available for items trashed from here" }
: {}),
...(process.platform === "darwin" ? { tooltip: t("ctx.trashTooltip") } : {}),
});
}
items.push({ label: "Delete", action: "ctx-delete" });
items.push({ label: t("ctx.delete"), action: "ctx-delete" });
return items;
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { t } from "../i18n";
import { LocalProvider } from "./local";
import type { FileSystemProvider } from "./provider";
@@ -15,7 +16,7 @@ export class SessionRegistry {
get(sessionId: string): FileSystemProvider {
const provider = this.sessions.get(sessionId);
if (!provider) throw new Error(`Session "${sessionId}" is not connected`);
if (!provider) throw new Error(t("errors.sessionNotConnected", { id: sessionId }));
return provider;
}
@@ -27,7 +28,7 @@ export class SessionRegistry {
async remove(sessionId: string): Promise<void> {
if (sessionId === LOCAL_SESSION_ID)
throw new Error("The local session cannot be disconnected");
throw new Error(t("errors.localCannotDisconnect"));
const provider = this.sessions.get(sessionId);
if (!provider) return;
this.sessions.delete(sessionId);
+3 -2
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
import { t } from "../i18n";
import { expandTilde } from "../store/ssh-config";
import type { FileSystemProvider } from "./provider";
@@ -63,7 +64,7 @@ export class SftpProvider implements FileSystemProvider {
connectConfig.password = config.auth.password;
break;
case "agent":
if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)");
if (!agentSock) throw new Error(t("errors.sshAgentUnavailable"));
connectConfig.agent = agentSock;
break;
case "key": {
@@ -73,7 +74,7 @@ export class SftpProvider implements FileSystemProvider {
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
} catch {
if (!agentSock)
throw new Error(`identity file not found: ${config.auth.privateKeyPath}`);
throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath }));
}
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
else if (agentSock) connectConfig.agent = agentSock;
+38
View File
@@ -0,0 +1,38 @@
import { DICTS, type Messages, resolveLocale } from "../shared/i18n";
import type { AppSettings, SupportedLocale } from "../shared/types";
// Backend-side translator for the native menus and thrown error messages
// (the Angular I18nService can't reach OS chrome). The locale is cached and
// refreshed from settings (see index.ts wiring); no signals here.
let locale: SupportedLocale = "en";
/** OS locale for the "system" setting, e.g. "es-ES" -> resolves to "es". */
function osLocale(): string {
try {
return new Intl.DateTimeFormat().resolvedOptions().locale;
} catch {
return "en";
}
}
export function setLocale(setting: AppSettings["locale"]): void {
locale = resolveLocale(setting, osLocale());
}
function lookup(dict: Messages, key: string): string | undefined {
let node: string | Messages | undefined = dict;
for (const part of key.split(".")) {
if (typeof node !== "object") return undefined;
node = node[part];
}
return typeof node === "string" ? node : undefined;
}
/** Translate a key with {name} interpolation; falls back to English then the key. */
export function t(key: string, params?: Record<string, string | number>): string {
const value = lookup(DICTS[locale], key) ?? lookup(DICTS.en, key) ?? key;
if (!params) return value;
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
}
+7
View File
@@ -8,6 +8,7 @@ import { EditorWindows } from "./editor-windows";
import { SessionRegistry } from "./fs/registry";
import { LocalDirWatcher } from "./fs/watcher";
import { ManagerWindow } from "./manager-window";
import { setLocale as setBackendLocale } from "./i18n";
import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers";
import { ConnectionStore } from "./store/connections";
@@ -64,6 +65,7 @@ const url = await getMainViewUrl();
// window's preload so the first paint is already themed (no flash). Kept
// fresh via the saveSettings flow below.
let currentSettings = await settings.load();
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
const isMac = process.platform === "darwin";
@@ -115,7 +117,12 @@ const manager = new ManagerWindow({
events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => {
const localeChanged = s.locale !== currentSettings.locale;
currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) {
setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language
}
editors.broadcastSettings(s);
manager.broadcastSettings(s);
};
+15 -14
View File
@@ -1,6 +1,7 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
import { BRANDING } from "../shared/branding";
import type { SavedConnection } from "../shared/types";
import { t } from "./i18n";
import type { ConnectionStore } from "./store/connections";
/**
@@ -27,37 +28,37 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
...looseItems,
...stackMenus,
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
{ label: "Connection Manager…", action: "manage-connections" },
{ label: t("menu.connectionManager"), action: "manage-connections" },
];
ApplicationMenu.setApplicationMenu([
{
submenu: [
{ label: `About ${BRANDING.appName}`, action: "about" },
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "showAll" },
{ type: "separator" },
{ label: "Quit", role: "quit" },
{ label: t("menu.quit"), role: "quit" },
],
},
{
label: "File",
label: t("menu.file"),
submenu: [
{ label: "New Tab", action: "new-tab", accelerator: "t" },
{ label: "Close Tab", action: "close-tab", accelerator: "w" },
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
{ type: "separator" },
{ label: "New Folder", action: "new-folder" },
{ label: "New File", action: "new-file" },
{ label: t("menu.newFolder"), action: "new-folder" },
{ label: t("menu.newFile"), action: "new-file" },
{ type: "separator" },
{ label: "Edit File", action: "edit-file", accelerator: "e" },
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
{ type: "separator" },
{ label: "Connect", submenu: connectSubmenu },
{ label: t("menu.connect"), submenu: connectSubmenu },
],
},
{
label: "Edit",
label: t("menu.edit"),
submenu: [
{ role: "undo" },
{ role: "redo" },
@@ -71,15 +72,15 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
],
},
{
label: "View",
label: t("menu.view"),
submenu: [
{ label: "Refresh", action: "refresh-active", accelerator: "r" },
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
{ type: "separator" },
{ role: "toggleFullScreen" },
],
},
{
label: "Window",
label: t("menu.window"),
submenu: [
{ role: "minimize" },
{ role: "zoom" },
+5 -4
View File
@@ -4,6 +4,7 @@ import { BRANDING } from "../../shared/branding";
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
import type { FileSystemProvider } from "../fs/provider";
import type { SessionRegistry } from "../fs/registry";
import { t } from "../i18n";
import { walkTree } from "./walk";
const PROGRESS_INTERVAL_MS = 100;
@@ -59,7 +60,7 @@ async function ensureDir(fs: FileSystemProvider, path: string): Promise<void> {
try {
const st = await fs.stat(path);
if (st.type === "dir") return;
throw new Error(`"${path}" exists and is not a directory`);
throw new Error(t("errors.notADirectory", { path }));
} catch {
await fs.mkdir(path);
}
@@ -235,7 +236,7 @@ export class TransferEngine {
if (sameFs) {
for (const file of files) {
if (file.srcPath === file.dstPath) {
throw new Error(`"${file.name}": source and destination are the same file`);
throw new Error(t("errors.sameFileTransfer", { name: file.name }));
}
}
}
@@ -343,10 +344,10 @@ export class TransferEngine {
if (samePath) {
throw new Error(
isDir ? "Cannot copy a folder onto itself" : "Source and destination are the same file",
isDir ? t("errors.folderOntoItself") : t("errors.sameFileNoName"),
);
}
throw new Error("Cannot copy a folder into itself");
throw new Error(t("errors.folderIntoItself"));
}
/**