mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Guard language switching failures (#1002)
This commit is contained in:
+2
-2
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user