From d908d9dc577451f61053494ad61bdb32766c590f Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Fri, 10 Jul 2026 08:01:48 +0800 Subject: [PATCH] Guard language switching failures (#1002) --- src/ui/AppShell.tsx | 4 +-- src/ui/auth/Auth.tsx | 12 ++++----- src/ui/i18n/i18n.test.ts | 21 ++++++++++++++++ src/ui/i18n/i18n.ts | 39 +++++++++++++++++++++-------- src/ui/sidebar/UserProfilePanel.tsx | 29 +++++++++++---------- src/ui/user/LanguageSwitcher.tsx | 12 ++++++--- 6 files changed, 80 insertions(+), 37 deletions(-) create mode 100644 src/ui/i18n/i18n.test.ts diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 0d0e6379..742a709b 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -63,6 +63,7 @@ import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel"; import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; +import { changeAppLanguage } from "@/i18n/i18n"; function buildHostTree( hosts: SSHHostWithStatus[], @@ -618,8 +619,7 @@ export function AppShell({ applyAccentColor(prefs.accentColor); } if (prefs.language && prefs.language !== i18n.language) { - localStorage.setItem("i18nextLng", prefs.language); - void i18n.changeLanguage(prefs.language); + void changeAppLanguage(prefs.language); } if ( prefs.commandAutocomplete !== null && diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index db4bad3d..b34a4c48 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -41,7 +41,7 @@ import type { SSOProviderPublic } from "@/types/index"; import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; import { Checkbox } from "@/components/checkbox"; -import i18n from "@/i18n/i18n"; +import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { removeSilentSigninFromSearch, shouldTriggerSilentSignin, @@ -238,14 +238,14 @@ export function Auth({ onLogin }: AuthProps) { const [confirmNewPassword, setConfirmNewPassword] = useState(""); const [resetTempToken, setResetTempToken] = useState(""); - const [language, setLanguage] = useState( - () => localStorage.getItem("i18nextLng") ?? "en", + const [language, setLanguage] = useState(() => + normalizeLanguageCode(localStorage.getItem("i18nextLng")), ); function handleLanguageChange(code: string) { - setLanguage(code); - localStorage.setItem("i18nextLng", code); - i18n.changeLanguage(code); + void changeAppLanguage(code) + .then((language) => setLanguage(language)) + .catch(() => {}); } const [registrationAllowed, setRegistrationAllowed] = useState(true); diff --git a/src/ui/i18n/i18n.test.ts b/src/ui/i18n/i18n.test.ts new file mode 100644 index 00000000..5df65c9a --- /dev/null +++ b/src/ui/i18n/i18n.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { changeAppLanguage, normalizeLanguageCode } from "./i18n"; + +describe("i18n language handling", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("normalizes persisted desktop language codes", () => { + expect(normalizeLanguageCode("zh_CN")).toBe("zh-CN"); + expect(normalizeLanguageCode("pt_br")).toBe("pt-BR"); + expect(normalizeLanguageCode("EN-us")).toBe("en"); + expect(normalizeLanguageCode("unknown")).toBe("en"); + }); + + it("stores the normalized language after a successful switch", async () => { + await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN"); + expect(localStorage.getItem("i18nextLng")).toBe("zh-CN"); + }); +}); diff --git a/src/ui/i18n/i18n.ts b/src/ui/i18n/i18n.ts index 392d1fe0..22686032 100644 --- a/src/ui/i18n/i18n.ts +++ b/src/ui/i18n/i18n.ts @@ -43,31 +43,43 @@ const localeLoaders = { "zh-TW": () => import("../locales/translated/zh_TW.json"), } satisfies Record Promise>; -const supportedLngs = ["en", ...Object.keys(localeLoaders)]; +export const supportedLngs = ["en", ...Object.keys(localeLoaders)]; + +export function normalizeLanguageCode(language?: string | null): string { + if (!language) return "en"; + + const normalized = language.replaceAll("_", "-"); + if (supportedLngs.includes(normalized)) return normalized; + + const exactMatch = supportedLngs.find( + (supported) => supported.toLowerCase() === normalized.toLowerCase(), + ); + if (exactMatch) return exactMatch; + + const baseLanguage = normalized.split("-")[0]; + return supportedLngs.includes(baseLanguage) ? baseLanguage : "en"; +} const localeBackend: BackendModule = { type: "backend", init: () => {}, read: (language, _namespace, callback) => { - if (language === "en") { + const normalizedLanguage = normalizeLanguageCode(language); + + if (normalizedLanguage === "en") { callback(null, enTranslation); return; } - const loadLocale = localeLoaders[language]; + const loadLocale = localeLoaders[normalizedLanguage]; if (!loadLocale) { - callback(new Error(`Unsupported language: ${language}`), false); + callback(null, enTranslation); return; } loadLocale() .then((module) => callback(null, module.default)) - .catch((error: unknown) => { - callback( - error instanceof Error ? error : new Error(String(error)), - false, - ); - }); + .catch(() => callback(null, enTranslation)); }, }; @@ -104,4 +116,11 @@ i18n }, }); +export async function changeAppLanguage(language: string): Promise { + const normalizedLanguage = normalizeLanguageCode(language); + await i18n.changeLanguage(normalizedLanguage); + localStorage.setItem("i18nextLng", normalizedLanguage); + return normalizedLanguage; +} + export default i18n; diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 32131c78..be357f55 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -78,7 +78,7 @@ import type { ApiKey } from "@/main-axios"; import { useTheme } from "@/components/theme-provider"; import type { FontSizeId, ThemeId } from "@/types/ui-types"; import { toast } from "sonner"; -import i18n from "@/i18n/i18n"; +import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; type UserProfileSection = | "account" @@ -515,8 +515,8 @@ export function UserProfilePanel({ const [fontSize, setFontSize] = useState( () => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md", ); - const [language, setLanguage] = useState( - () => localStorage.getItem("i18nextLng") ?? "en", + const [language, setLanguage] = useState(() => + normalizeLanguageCode(localStorage.getItem("i18nextLng")), ); const [storageMode, setStorageMode] = useState<"local" | "cloud">(() => userPrefs?.storageMode === "cloud" ? "cloud" : "local", @@ -662,9 +662,8 @@ export function UserProfilePanel({ applyAccentColor(prefs.accentColor); } if (prefs.language) { - setLanguage(prefs.language); - localStorage.setItem("i18nextLng", prefs.language); - void i18n.changeLanguage(prefs.language); + const language = await changeAppLanguage(prefs.language); + setLanguage(language); } if (prefs.commandAutocomplete != null) { setCommandAutocomplete(prefs.commandAutocomplete); @@ -772,8 +771,7 @@ export function UserProfilePanel({ localStorage.setItem("termix-accent", DEFAULT_ACCENT); applyAccentColor(DEFAULT_ACCENT); setLanguage("en"); - localStorage.setItem("i18nextLng", "en"); - void i18n.changeLanguage("en"); + void changeAppLanguage("en"); setCommandAutocomplete(false); localStorage.setItem("commandAutocomplete", "false"); setCommandPaletteEnabled(true); @@ -862,10 +860,9 @@ export function UserProfilePanel({ localStorage.setItem("termix-accent", restoredAccent); applyAccentColor(restoredAccent); - const restoredLang = restore("i18nextLng", "en") ?? "en"; + const restoredLang = normalizeLanguageCode(restore("i18nextLng", "en")); setLanguage(restoredLang); - localStorage.setItem("i18nextLng", restoredLang); - void i18n.changeLanguage(restoredLang); + void changeAppLanguage(restoredLang); const restoredAutocomplete = restore("commandAutocomplete", "false") === "true"; @@ -984,10 +981,12 @@ export function UserProfilePanel({ } function handleLanguageChange(code: string) { - setLanguage(code); - localStorage.setItem("i18nextLng", code); - i18n.changeLanguage(code); - if (storageMode === "cloud") saveToCloud({ language: code }); + void changeAppLanguage(code) + .then((language) => { + setLanguage(language); + if (storageMode === "cloud") saveToCloud({ language }); + }) + .catch(() => {}); } function toggle(id: UserProfileSection) { diff --git a/src/ui/user/LanguageSwitcher.tsx b/src/ui/user/LanguageSwitcher.tsx index b243f3d4..e0a4a6a8 100644 --- a/src/ui/user/LanguageSwitcher.tsx +++ b/src/ui/user/LanguageSwitcher.tsx @@ -9,6 +9,7 @@ import { } from "@/components/select.tsx"; import { Globe } from "lucide-react"; import { saveUserPreferences } from "@/main-axios"; +import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; const languages = [ { code: "en", name: "English", nativeName: "English" }, @@ -60,15 +61,18 @@ export function LanguageSwitcher() { const { i18n, t } = useTranslation(); const handleLanguageChange = (value: string) => { - i18n.changeLanguage(value); - localStorage.setItem("i18nextLng", value); - saveUserPreferences({ language: value }).catch(() => {}); + void changeAppLanguage(value) + .then((language) => saveUserPreferences({ language })) + .catch(() => {}); }; return (
-