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 { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
||||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||||
|
import { changeAppLanguage } from "@/i18n/i18n";
|
||||||
|
|
||||||
function buildHostTree(
|
function buildHostTree(
|
||||||
hosts: SSHHostWithStatus[],
|
hosts: SSHHostWithStatus[],
|
||||||
@@ -618,8 +619,7 @@ export function AppShell({
|
|||||||
applyAccentColor(prefs.accentColor);
|
applyAccentColor(prefs.accentColor);
|
||||||
}
|
}
|
||||||
if (prefs.language && prefs.language !== i18n.language) {
|
if (prefs.language && prefs.language !== i18n.language) {
|
||||||
localStorage.setItem("i18nextLng", prefs.language);
|
void changeAppLanguage(prefs.language);
|
||||||
void i18n.changeLanguage(prefs.language);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
prefs.commandAutocomplete !== null &&
|
prefs.commandAutocomplete !== null &&
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import type { SSOProviderPublic } from "@/types/index";
|
|||||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||||
import { Checkbox } from "@/components/checkbox";
|
import { Checkbox } from "@/components/checkbox";
|
||||||
import i18n from "@/i18n/i18n";
|
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||||
import {
|
import {
|
||||||
removeSilentSigninFromSearch,
|
removeSilentSigninFromSearch,
|
||||||
shouldTriggerSilentSignin,
|
shouldTriggerSilentSignin,
|
||||||
@@ -238,14 +238,14 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||||
const [resetTempToken, setResetTempToken] = useState("");
|
const [resetTempToken, setResetTempToken] = useState("");
|
||||||
|
|
||||||
const [language, setLanguage] = useState(
|
const [language, setLanguage] = useState(() =>
|
||||||
() => localStorage.getItem("i18nextLng") ?? "en",
|
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleLanguageChange(code: string) {
|
function handleLanguageChange(code: string) {
|
||||||
setLanguage(code);
|
void changeAppLanguage(code)
|
||||||
localStorage.setItem("i18nextLng", code);
|
.then((language) => setLanguage(language))
|
||||||
i18n.changeLanguage(code);
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
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"),
|
"zh-TW": () => import("../locales/translated/zh_TW.json"),
|
||||||
} satisfies Record<string, () => Promise<LocaleModule>>;
|
} 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 = {
|
const localeBackend: BackendModule = {
|
||||||
type: "backend",
|
type: "backend",
|
||||||
init: () => {},
|
init: () => {},
|
||||||
read: (language, _namespace, callback) => {
|
read: (language, _namespace, callback) => {
|
||||||
if (language === "en") {
|
const normalizedLanguage = normalizeLanguageCode(language);
|
||||||
|
|
||||||
|
if (normalizedLanguage === "en") {
|
||||||
callback(null, enTranslation);
|
callback(null, enTranslation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadLocale = localeLoaders[language];
|
const loadLocale = localeLoaders[normalizedLanguage];
|
||||||
if (!loadLocale) {
|
if (!loadLocale) {
|
||||||
callback(new Error(`Unsupported language: ${language}`), false);
|
callback(null, enTranslation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadLocale()
|
loadLocale()
|
||||||
.then((module) => callback(null, module.default))
|
.then((module) => callback(null, module.default))
|
||||||
.catch((error: unknown) => {
|
.catch(() => callback(null, enTranslation));
|
||||||
callback(
|
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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;
|
export default i18n;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ import type { ApiKey } from "@/main-axios";
|
|||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
import type { FontSizeId, ThemeId } from "@/types/ui-types";
|
import type { FontSizeId, ThemeId } from "@/types/ui-types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import i18n from "@/i18n/i18n";
|
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||||
|
|
||||||
type UserProfileSection =
|
type UserProfileSection =
|
||||||
| "account"
|
| "account"
|
||||||
@@ -515,8 +515,8 @@ export function UserProfilePanel({
|
|||||||
const [fontSize, setFontSize] = useState<FontSizeId>(
|
const [fontSize, setFontSize] = useState<FontSizeId>(
|
||||||
() => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md",
|
() => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md",
|
||||||
);
|
);
|
||||||
const [language, setLanguage] = useState(
|
const [language, setLanguage] = useState(() =>
|
||||||
() => localStorage.getItem("i18nextLng") ?? "en",
|
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||||
);
|
);
|
||||||
const [storageMode, setStorageMode] = useState<"local" | "cloud">(() =>
|
const [storageMode, setStorageMode] = useState<"local" | "cloud">(() =>
|
||||||
userPrefs?.storageMode === "cloud" ? "cloud" : "local",
|
userPrefs?.storageMode === "cloud" ? "cloud" : "local",
|
||||||
@@ -662,9 +662,8 @@ export function UserProfilePanel({
|
|||||||
applyAccentColor(prefs.accentColor);
|
applyAccentColor(prefs.accentColor);
|
||||||
}
|
}
|
||||||
if (prefs.language) {
|
if (prefs.language) {
|
||||||
setLanguage(prefs.language);
|
const language = await changeAppLanguage(prefs.language);
|
||||||
localStorage.setItem("i18nextLng", prefs.language);
|
setLanguage(language);
|
||||||
void i18n.changeLanguage(prefs.language);
|
|
||||||
}
|
}
|
||||||
if (prefs.commandAutocomplete != null) {
|
if (prefs.commandAutocomplete != null) {
|
||||||
setCommandAutocomplete(prefs.commandAutocomplete);
|
setCommandAutocomplete(prefs.commandAutocomplete);
|
||||||
@@ -772,8 +771,7 @@ export function UserProfilePanel({
|
|||||||
localStorage.setItem("termix-accent", DEFAULT_ACCENT);
|
localStorage.setItem("termix-accent", DEFAULT_ACCENT);
|
||||||
applyAccentColor(DEFAULT_ACCENT);
|
applyAccentColor(DEFAULT_ACCENT);
|
||||||
setLanguage("en");
|
setLanguage("en");
|
||||||
localStorage.setItem("i18nextLng", "en");
|
void changeAppLanguage("en");
|
||||||
void i18n.changeLanguage("en");
|
|
||||||
setCommandAutocomplete(false);
|
setCommandAutocomplete(false);
|
||||||
localStorage.setItem("commandAutocomplete", "false");
|
localStorage.setItem("commandAutocomplete", "false");
|
||||||
setCommandPaletteEnabled(true);
|
setCommandPaletteEnabled(true);
|
||||||
@@ -862,10 +860,9 @@ export function UserProfilePanel({
|
|||||||
localStorage.setItem("termix-accent", restoredAccent);
|
localStorage.setItem("termix-accent", restoredAccent);
|
||||||
applyAccentColor(restoredAccent);
|
applyAccentColor(restoredAccent);
|
||||||
|
|
||||||
const restoredLang = restore("i18nextLng", "en") ?? "en";
|
const restoredLang = normalizeLanguageCode(restore("i18nextLng", "en"));
|
||||||
setLanguage(restoredLang);
|
setLanguage(restoredLang);
|
||||||
localStorage.setItem("i18nextLng", restoredLang);
|
void changeAppLanguage(restoredLang);
|
||||||
void i18n.changeLanguage(restoredLang);
|
|
||||||
|
|
||||||
const restoredAutocomplete =
|
const restoredAutocomplete =
|
||||||
restore("commandAutocomplete", "false") === "true";
|
restore("commandAutocomplete", "false") === "true";
|
||||||
@@ -984,10 +981,12 @@ export function UserProfilePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLanguageChange(code: string) {
|
function handleLanguageChange(code: string) {
|
||||||
setLanguage(code);
|
void changeAppLanguage(code)
|
||||||
localStorage.setItem("i18nextLng", code);
|
.then((language) => {
|
||||||
i18n.changeLanguage(code);
|
setLanguage(language);
|
||||||
if (storageMode === "cloud") saveToCloud({ language: code });
|
if (storageMode === "cloud") saveToCloud({ language });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(id: UserProfileSection) {
|
function toggle(id: UserProfileSection) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/components/select.tsx";
|
} from "@/components/select.tsx";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { saveUserPreferences } from "@/main-axios";
|
import { saveUserPreferences } from "@/main-axios";
|
||||||
|
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: "en", name: "English", nativeName: "English" },
|
{ code: "en", name: "English", nativeName: "English" },
|
||||||
@@ -60,15 +61,18 @@ export function LanguageSwitcher() {
|
|||||||
const { i18n, t } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
|
|
||||||
const handleLanguageChange = (value: string) => {
|
const handleLanguageChange = (value: string) => {
|
||||||
i18n.changeLanguage(value);
|
void changeAppLanguage(value)
|
||||||
localStorage.setItem("i18nextLng", value);
|
.then((language) => saveUserPreferences({ language }))
|
||||||
saveUserPreferences({ language: value }).catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 relative z-[99999]">
|
<div className="flex items-center gap-2 relative z-[99999]">
|
||||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
<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]">
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectValue placeholder={t("placeholders.language")} />
|
<SelectValue placeholder={t("placeholders.language")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
Reference in New Issue
Block a user