Guard language switching failures (#1002)

This commit is contained in:
ZacharyZcR
2026-07-10 08:01:48 +08:00
committed by GitHub
parent 57effd2405
commit d908d9dc57
6 changed files with 80 additions and 37 deletions
+2 -2
View File
@@ -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 &&
+6 -6
View File
@@ -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);
+21
View File
@@ -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");
});
});
+29 -10
View File
@@ -43,31 +43,43 @@ const localeLoaders = {
"zh-TW": () => import("../locales/translated/zh_TW.json"),
} satisfies Record<string, () => Promise<LocaleModule>>;
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<string> {
const normalizedLanguage = normalizeLanguageCode(language);
await i18n.changeLanguage(normalizedLanguage);
localStorage.setItem("i18nextLng", normalizedLanguage);
return normalizedLanguage;
}
export default i18n;
+14 -15
View File
@@ -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<FontSizeId>(
() => (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) {
+8 -4
View File
@@ -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 (
<div className="flex items-center gap-2 relative z-[99999]">
<Globe className="h-4 w-4 text-muted-foreground" />
<Select value={i18n.language} onValueChange={handleLanguageChange}>
<Select
value={normalizeLanguageCode(i18n.resolvedLanguage || i18n.language)}
onValueChange={handleLanguageChange}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("placeholders.language")} />
</SelectTrigger>