дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
* perf(vfs): list directories off the main thread and reuse the repository lookup Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow because every listing ran on the main thread and walked up to the repository root and ran a repository discovery again. - fs_read_dir runs on the blocking pool. - GitignoreStack::cached keeps the repository root and the repo-level rules for a few seconds. An edit of .git/info/exclude is still seen at once (its mtime and size are checked) and every .gitignore / .ignore is read fresh. Refs #916 * feat(remote): open WSL projects through a WSL transport A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens as a remote project whose openidea-server runs inside the distro, so the terminal, SDK discovery, run, language servers and git are native Linux. - RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote launch, connection, session and recents; old recents are read as SSH and a WSL target never inherits the legacy trust migration. - remote_wsl: probe the distro, stream the server into ~/.openidea/server and start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are validated and every command goes through --exec with positional arguments. - wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route WSL folders to the distro and focus the window that already has them. - Remote Development dialog: SSH/WSL choice with the detected distros; the status bar shows `WSL: <distro>` / `SSH: <host>`. - ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of wsl.exe, which made the first message of the server disappear. Not run on Windows yet: only a stand-in for wsl.exe is tested. Refs #925, #327, #721 * fix(remote): satisfy clippy in WSL output decoding * style(run): format run types * fix: regenerate bindings and drop untranslated WSL catalog entry * test(i18n): list WSL as an untranslated product name * fix(run): pick build wrappers and shell quoting by the execution host A remote window runs commands on its Linux or macOS server, so a Windows desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it. * fix(welcome): open legacy WSL UNC recents and sessions through the distro Recents and the restored session called the local openProject for a \\wsl.localhost folder saved before WSL projects, which fails with OS error 123. They now resolve it like the other entry points. * fix(windows): keep WSL files out of remote project launches path_launch tested the WSL share before is_dir, so a first-instance CLI launch with a WSL file opened a remote project instead of LightEdit. * fix(remote): run-command OS comes from the remote host, not an assumption The server's OS was dropped after connect, so a restored session had none and isWindows() assumed every remote host is Unix. Keep it on the connection and the session, and resolve it through hostOs(). --------- Co-authored-by: NguyenBang <[email protected]> Co-authored-by: NguyenBang <[email protected]> Co-authored-by: l0ng-ai <[email protected]>
297 рядки
13 KiB
TypeScript
297 рядки
13 KiB
TypeScript
import { readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
_resetI18nForTests,
|
|
formatMessage,
|
|
isValidMessage,
|
|
keysWithPrefix,
|
|
registerMessages,
|
|
resolveLocale,
|
|
setLocale,
|
|
SUPPORTED_LOCALES,
|
|
t,
|
|
tx,
|
|
txTemplate,
|
|
useI18n,
|
|
} from "./i18n";
|
|
import en from "./locales/en.json";
|
|
|
|
afterEach(() => _resetI18nForTests());
|
|
|
|
describe("formatMessage", () => {
|
|
it("substitutes parameters and leaves unknown ones", () => {
|
|
expect(formatMessage("Open {name}", { name: "openidea" })).toBe("Open openidea");
|
|
expect(formatMessage("Open {name} in {where}", { name: "openidea" })).toBe("Open openidea in {where}");
|
|
expect(formatMessage("plain text")).toBe("plain text");
|
|
});
|
|
|
|
it("formats numbers per locale", () => {
|
|
expect(formatMessage("{n} files", { n: 12345 }, "en")).toBe("12,345 files");
|
|
expect(formatMessage("{n, number}", { n: 1234.5 }, "en")).toBe("1,234.5");
|
|
});
|
|
|
|
it("selects plural categories, exact matches first", () => {
|
|
const m = "{count, plural, =0 {No files} one {# file} other {# files}}";
|
|
expect(formatMessage(m, { count: 0 })).toBe("No files");
|
|
expect(formatMessage(m, { count: 1 })).toBe("1 file");
|
|
expect(formatMessage(m, { count: 2 })).toBe("2 files");
|
|
expect(formatMessage(m, { count: 1000 })).toBe("1,000 files");
|
|
});
|
|
|
|
it("uses CLDR rules of the locale (Chinese has only `other`)", () => {
|
|
const m = "{count, plural, one {# one} other {# other}}";
|
|
expect(formatMessage(m, { count: 1 }, "zh")).toBe("1 other");
|
|
expect(formatMessage(m, { count: 1 }, "en")).toBe("1 one");
|
|
});
|
|
|
|
it("uses CLDR rules of the locale (French zero is singular, `many` falls back to `other`)", () => {
|
|
const m = "{count, plural, one {# fichier} other {# fichiers}}";
|
|
expect(formatMessage(m, { count: 0 }, "fr")).toBe("0 fichier");
|
|
expect(formatMessage(m, { count: 2 }, "fr")).toBe("2 fichiers");
|
|
expect(formatMessage(m, { count: 1_000_000 }, "fr")).toBe(
|
|
`${new Intl.NumberFormat("fr").format(1_000_000)} fichiers`,
|
|
);
|
|
});
|
|
|
|
it("supports select and nesting", () => {
|
|
const m = "{os, select, macos {Finder} windows {Explorer} other {Files}}";
|
|
expect(formatMessage(m, { os: "macos" })).toBe("Finder");
|
|
expect(formatMessage(m, { os: "linux" })).toBe("Files");
|
|
const nested = "{count, plural, one {{who} has # file} other {{who} has # files}}";
|
|
expect(formatMessage(nested, { count: 3, who: "Ann" })).toBe("Ann has 3 files");
|
|
const deep = "{os, select, macos {{count, plural, one {# window} other {# windows}}} other {#}}";
|
|
expect(formatMessage(deep, { os: "macos", count: 2 })).toBe("2 windows");
|
|
});
|
|
|
|
it("falls back to `other` when the parameter is missing", () => {
|
|
expect(formatMessage("{n, plural, one {one} other {many}}")).toBe("many");
|
|
});
|
|
|
|
it("returns malformed messages unformatted", () => {
|
|
expect(formatMessage("{n, plural, one {x}", { n: 1 })).toBe("{n, plural, one {x}");
|
|
expect(formatMessage("{n, weird, a {b}}", { n: 1 })).toBe("{n, weird, a {b}}");
|
|
expect(isValidMessage("{n, plural, one {x}")).toBe(false);
|
|
expect(isValidMessage("{n, plural, one {x} other {y}}")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("t / locales", () => {
|
|
it("resolves locale tags", () => {
|
|
expect(resolveLocale("zh-CN")).toBe("zh");
|
|
expect(resolveLocale("zh_Hans")).toBe("zh");
|
|
expect(resolveLocale("ko-KR")).toBe("ko");
|
|
expect(resolveLocale("pt-BR")).toBe("pt");
|
|
expect(resolveLocale("pt_PT")).toBe("pt");
|
|
expect(resolveLocale("EN-us")).toBe("en");
|
|
expect(resolveLocale("fr")).toBe("fr");
|
|
expect(resolveLocale("fr-CA")).toBe("fr");
|
|
expect(resolveLocale("pl_PL")).toBe("pl");
|
|
expect(resolveLocale("pl-PL")).toBe("pl");
|
|
expect(resolveLocale("es-MX")).toBe("es");
|
|
expect(resolveLocale("es_MX")).toBe("es");
|
|
expect(resolveLocale("xx")).toBe("en");
|
|
expect(resolveLocale(undefined)).toBe(resolveLocale(navigator.language));
|
|
});
|
|
|
|
it("translates with English fallback, then the key itself", async () => {
|
|
expect(t("common.cancel")).toBe("Cancel");
|
|
await setLocale("zh");
|
|
expect(useI18n.getState().locale).toBe("zh");
|
|
expect(t("common.cancel")).toBe("取消");
|
|
registerMessages("en", { "test.onlyEnglish": "Only English" });
|
|
expect(t("test.onlyEnglish")).toBe("Only English");
|
|
expect(t("test.missing.key")).toBe("test.missing.key");
|
|
});
|
|
|
|
it("applies params and plurals in the active locale", async () => {
|
|
expect(t("app.unsaved.body", { count: 1, project: "openidea" })).toBe(
|
|
"1 file has unsaved changes in openidea. Save before closing?",
|
|
);
|
|
await setLocale("zh-CN");
|
|
expect(t("app.keymap.count", { count: 1 })).toBe("1 个快捷键");
|
|
await setLocale("pt-BR");
|
|
expect(t("app.keymap.count", { count: 1 })).toBe("1 atalho");
|
|
expect(t("app.keymap.count", { count: 5 })).toBe("5 atalhos");
|
|
});
|
|
|
|
it("switches to Korean", async () => {
|
|
await setLocale("ko-KR");
|
|
expect(useI18n.getState().locale).toBe("ko");
|
|
expect(t("common.cancel")).toBe("취소");
|
|
expect(tx("Cancel")).toBe("취소");
|
|
});
|
|
|
|
it("falls back to English for unsupported locales", async () => {
|
|
await setLocale("xx");
|
|
expect(useI18n.getState().locale).toBe("en");
|
|
});
|
|
|
|
it("registerMessages adds and removes feature strings", async () => {
|
|
const off = registerMessages("zh", { "feature.hello": "你好" });
|
|
registerMessages("en", { "feature.hello": "Hello" });
|
|
expect(t("feature.hello")).toBe("Hello");
|
|
await setLocale("zh");
|
|
expect(t("feature.hello")).toBe("你好");
|
|
off();
|
|
expect(t("feature.hello")).toBe("Hello");
|
|
});
|
|
|
|
it("translates source text through the locale's text catalog", async () => {
|
|
await setLocale("en");
|
|
expect(tx("Sync with OS")).toBe("Sync with OS");
|
|
await setLocale("zh");
|
|
expect(tx("Sync with OS")).toBe("与操作系统同步");
|
|
expect(tx("Some text nobody translated")).toBe("Some text nobody translated");
|
|
expect(tx(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("translates a tx tag without values like the same text as a string", async () => {
|
|
await setLocale("zh");
|
|
expect(tx`Sync with OS`).toBe("与操作系统同步");
|
|
expect(tx`Sync with OS`).toBe(tx("Sync with OS"));
|
|
expect(tx`Some text nobody translated`).toBe("Some text nobody translated");
|
|
});
|
|
|
|
it("matches source-text templates, translating the substituted text", async () => {
|
|
expect(txTemplate("Push failed")).toBe("Push failed");
|
|
await setLocale("zh");
|
|
expect(txTemplate("ruff is not installed")).toBe("ruff 未安装");
|
|
expect(txTemplate("Language server 'gopls' is not installed")).toBe("语言服务器 'gopls' 未安装");
|
|
expect(txTemplate("Retry failed")).toBe(`${tx("Retry")}失败`);
|
|
expect(txTemplate("Sync with OS")).toBe("与操作系统同步");
|
|
expect(txTemplate("Something else entirely")).toBe("Something else entirely");
|
|
});
|
|
|
|
it("translates text built by the tx tag, again after the locale changes", async () => {
|
|
await setLocale("en");
|
|
const name = "orders";
|
|
const built = tx`Run '${name}'`;
|
|
expect(built).toBe("Run 'orders'");
|
|
expect(tx`Unknown ${name} text`).toBe("Unknown orders text");
|
|
await setLocale("zh");
|
|
// Built before the catalog loaded: primitives passing it through `tx` translate it.
|
|
expect(tx(built)).toBe("运行 'orders'");
|
|
expect(txTemplate(built)).toBe("运行 'orders'");
|
|
const zh = tx`Run '${name}'`;
|
|
expect(zh).toBe("运行 'orders'");
|
|
// Substituted text is translated when the catalog has it.
|
|
expect(tx`Run '${"Retry"}'`).toBe(`运行 '${tx("Retry")}'`);
|
|
// Values are inserted as they are, braces included.
|
|
expect(tx`Drop ${"stash@{0}"}? This cannot be undone.`).toBe("是否丢弃 stash@{0}?此操作无法撤消。");
|
|
await setLocale("en");
|
|
expect(tx(zh)).toBe("Run 'orders'");
|
|
});
|
|
|
|
it("loads the Portuguese catalogs", async () => {
|
|
await setLocale("pt-BR");
|
|
expect(useI18n.getState().locale).toBe("pt");
|
|
expect(t("common.cancel")).toBe("Cancelar");
|
|
expect(t("app.keymap.count", { count: 1 })).toBe("1 atalho");
|
|
expect(t("app.keymap.count", { count: 5 })).toBe("5 atalhos");
|
|
});
|
|
|
|
it("loads the French catalogs", async () => {
|
|
await setLocale("fr-FR");
|
|
expect(useI18n.getState().locale).toBe("fr");
|
|
expect(t("common.cancel")).toBe("Annuler");
|
|
expect(t("app.keymap.count", { count: 0 })).toBe("0 raccourci");
|
|
expect(tx("Sync with OS")).toBe("Synchroniser avec le système");
|
|
expect(tx`Run '${"orders"}'`).toBe("Exécuter 'orders'");
|
|
expect(txTemplate("ruff is not installed")).toBe("ruff n'est pas installé");
|
|
});
|
|
|
|
it("loads the Polish catalogs", async () => {
|
|
await setLocale("pl-PL");
|
|
expect(useI18n.getState().locale).toBe("pl");
|
|
expect(t("common.cancel")).toBe("Anuluj");
|
|
expect(t("app.keymap.count", { count: 1 })).toBe("1 skrót");
|
|
expect(t("app.keymap.count", { count: 5 })).toBe("5 skrótów");
|
|
});
|
|
|
|
it("loads the Spanish catalogs", async () => {
|
|
await setLocale("es-MX");
|
|
expect(useI18n.getState().locale).toBe("es");
|
|
expect(t("common.cancel")).toBe("Cancelar");
|
|
expect(t("app.keymap.count", { count: 1 })).toBe("1 atajo");
|
|
expect(t("app.keymap.count", { count: 5 })).toBe("5 atajos");
|
|
});
|
|
|
|
it("lists keys by prefix", () => {
|
|
const keys = keysWithPrefix("menu.");
|
|
expect(keys).toContain("menu.file.save");
|
|
expect(keys.every((k) => k.startsWith("menu."))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("catalogs", () => {
|
|
type Catalog = Record<string, string>;
|
|
const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
|
|
/** Catalog files of a folder as `{ locale: catalog }`, read from disk so that a new file is always seen. */
|
|
const readCatalogs = (dir: string) =>
|
|
Object.fromEntries(
|
|
readdirSync(dir)
|
|
.filter((file) => file.endsWith(".json"))
|
|
.map((file) => [file.slice(0, -5), JSON.parse(readFileSync(join(dir, file), "utf8")) as Catalog]),
|
|
);
|
|
|
|
const { en: _en, ...translations } = readCatalogs(LOCALES_DIR);
|
|
const texts = readCatalogs(join(LOCALES_DIR, "text"));
|
|
const catalogs: Record<string, Catalog> = { en, ...translations };
|
|
/** Must have every key; the other locales show English for keys they lack. */
|
|
const COMPLETE = ["zh"];
|
|
/** Written in another script, so a text equal to its English source was not translated. */
|
|
const OWN_SCRIPT = new Set(["zh", "ko"]);
|
|
const translated = SUPPORTED_LOCALES.filter((locale) => locale !== "en").sort();
|
|
|
|
it("exist for exactly the supported locales", () => {
|
|
expect(translated.length).toBeGreaterThan(0);
|
|
expect(Object.keys(translations).sort()).toEqual(translated);
|
|
expect(Object.keys(texts).sort()).toEqual([...SUPPORTED_LOCALES].sort());
|
|
});
|
|
|
|
it("translate source text into non-empty text", () => {
|
|
for (const [locale, catalog] of Object.entries(texts))
|
|
for (const [source, text] of Object.entries(catalog)) expect(text.trim(), `${locale}:${source}`).not.toBe("");
|
|
});
|
|
|
|
it("translate source text into text different from English, in locales with another script", () => {
|
|
for (const [locale, catalog] of Object.entries(texts))
|
|
if (OWN_SCRIPT.has(locale))
|
|
for (const [source, text] of Object.entries(catalog)) expect(text, `${locale}:${source}`).not.toBe(source);
|
|
});
|
|
|
|
it("keep the placeholders of source-text templates", () => {
|
|
const names = (m: string) => [...m.matchAll(/\{(\w+)\}/g)].map((x) => x[1]).sort();
|
|
for (const [locale, catalog] of Object.entries(texts))
|
|
for (const [source, text] of Object.entries(catalog))
|
|
expect(names(text), `${locale}:${source}`).toEqual(names(source));
|
|
});
|
|
|
|
it.each(COMPLETE)("have the same keys as English (%s)", (locale) => {
|
|
expect(Object.keys(translations[locale]!).sort()).toEqual(Object.keys(en).sort());
|
|
});
|
|
|
|
it("have no keys English lacks", () => {
|
|
for (const [locale, catalog] of Object.entries(translations))
|
|
expect(
|
|
Object.keys(catalog).filter((key) => !Object.hasOwn(en, key)),
|
|
locale,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("contain only well-formed messages", () => {
|
|
for (const [locale, catalog] of Object.entries(catalogs))
|
|
for (const [key, message] of Object.entries(catalog))
|
|
expect(isValidMessage(message), `${locale}:${key}`).toBe(true);
|
|
});
|
|
|
|
it("use the same parameters across locales", () => {
|
|
const params = (m: string) => [...m.matchAll(/\{\s*([a-z]\w*)\s*[,}]/g)].map((x) => x[1]).sort();
|
|
for (const [locale, catalog] of Object.entries(translations))
|
|
for (const [key, message] of Object.entries(catalog))
|
|
expect(params(message), `${locale}:${key}`).toEqual(params((en as Record<string, string>)[key] ?? ""));
|
|
});
|
|
});
|