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:
+29
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}}`,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user