дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(plugins): let a plugin ship translations and read the UI language (#1781)
A package's locales/<tag>.json (English source text -> translation) is applied to the plugin's own manifest titles and labels and to its view data; new ui.locale() (API 1.3) returns the UI language for text the plugin builds itself. Catalogs only ever apply to their own plugin; text without an entry stays English. Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
1 джерело
e0408cc7d7
коміт
adc308eb99
50 files changed
+596
-40
No files matched your search
@@ -33,6 +33,7 @@ import {
|
||||
} from "@/ui";
|
||||
import { RISK_LABEL, sortByRisk } from "./consent";
|
||||
import { pluginIconName } from "./pluginIcon";
|
||||
import { localizeContributions, usePluginCatalogs, usePluginT } from "./pluginText";
|
||||
import { itemsOf, uiPoint } from "./projection";
|
||||
import {
|
||||
compareVersions,
|
||||
@@ -75,6 +76,7 @@ const POINT_TITLES: Record<string, string> = {
|
||||
iconThemes: "File Icon Themes",
|
||||
languages: "Languages",
|
||||
providers: "Editor Providers",
|
||||
locales: "Translations",
|
||||
};
|
||||
|
||||
const pointTitle = (point: string) => uiPoint(point)?.title ?? POINT_TITLES[point] ?? point;
|
||||
@@ -268,7 +270,9 @@ function pointsOf(plugin: PluginInfo): string[] {
|
||||
function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) {
|
||||
const ui = usePlugins((s) => s.ui);
|
||||
const Details = uiPoint(point)?.Details;
|
||||
const items = itemsOf(ui, point, plugin.id);
|
||||
const catalogs = usePluginCatalogs((s) => s.catalogs);
|
||||
const t = usePluginT(plugin.id);
|
||||
const items = itemsOf(localizeContributions(ui, catalogs), point, plugin.id);
|
||||
return (
|
||||
<Section title={pointTitle(point)}>
|
||||
{Details && items.length > 0 ? (
|
||||
@@ -279,7 +283,7 @@ function PointSection({ plugin, point }: { plugin: PluginInfo; point: string })
|
||||
.filter((c) => c.point === point)
|
||||
.map((c, i) => (
|
||||
<li key={`${c.label}-${i}`} className={styles.item}>
|
||||
<span className={styles.itemMain}>{c.label}</span>
|
||||
<span className={styles.itemMain}>{t(c.label)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { useI18n } from "@/platform/i18n";
|
||||
import { useProducerNames } from "@/platform/inspections";
|
||||
import { languageListChanged } from "@/platform/languageList";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import { useProject } from "@/platform/project";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { trackIconTheme } from "./iconThemes";
|
||||
import { defineBuiltinUiPoints } from "./points";
|
||||
import { localizeContributions, loadPluginCatalogs, usePluginCatalogs } from "./pluginText";
|
||||
import { clearUiContributions, syncUiContributions } from "./projection";
|
||||
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store";
|
||||
@@ -42,16 +44,23 @@ export { pluginActionId } from "./points";
|
||||
export function syncProjection(): () => void {
|
||||
const apply = () => {
|
||||
const s = usePlugins.getState();
|
||||
syncUiContributions(s.ui, s.plugins);
|
||||
syncUiContributions(localizeContributions(s.ui, usePluginCatalogs.getState().catalogs), s.plugins);
|
||||
// Their problems are listed by name in Settings › Inspections.
|
||||
useProducerNames.setState({ names: new Map(s.plugins.map((p) => [`plugin:${p.id}`, p.name])) });
|
||||
};
|
||||
const loadCatalogs = () => void loadPluginCatalogs(usePlugins.getState().ui, useI18n.getState().locale);
|
||||
apply();
|
||||
const unsubscribe = usePlugins.subscribe((s, prev) => {
|
||||
if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply();
|
||||
});
|
||||
loadCatalogs();
|
||||
const unsubscribe = [
|
||||
usePlugins.subscribe((s, prev) => {
|
||||
if (s.ui !== prev.ui) loadCatalogs();
|
||||
if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply();
|
||||
}),
|
||||
usePluginCatalogs.subscribe((s, prev) => s.catalogs !== prev.catalogs && apply()),
|
||||
useI18n.subscribe((s, prev) => s.locale !== prev.locale && loadCatalogs()),
|
||||
];
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribe.forEach((u) => u());
|
||||
clearUiContributions();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UiContribution, View } from "@/bindings";
|
||||
import { localizeContributions, localizeView, pickCatalogTag, translatorOf } from "./pluginText";
|
||||
|
||||
const fr = { Refresh: "Actualiser", "Word Tools": "Outils de mots", Cancel: "Annuler" };
|
||||
|
||||
describe("pickCatalogTag", () => {
|
||||
it.each([
|
||||
[["fr", "zh-CN"], "fr", "fr"],
|
||||
[["fr", "zh-CN"], "zh", "zh-CN"],
|
||||
[["zh"], "zh", "zh"],
|
||||
[["zh-TW"], "zh", "zh-TW"],
|
||||
[["pt-br"], "pt", "pt-br"],
|
||||
[["fr"], "en", undefined],
|
||||
[[], "fr", undefined],
|
||||
])("%j for %s is %s", (tags, locale, expected) => {
|
||||
expect(pickCatalogTag(tags, locale)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeContributions", () => {
|
||||
const item = (pluginId: string, point: string, decl: Record<string, unknown>): UiContribution => ({
|
||||
pluginId,
|
||||
point,
|
||||
key: String(decl.id),
|
||||
decl,
|
||||
});
|
||||
|
||||
it("translates titles and setting labels of the plugin's own declarations only", () => {
|
||||
const items = [
|
||||
item("a", "commands", { id: "x", title: "Refresh", category: "Word Tools" }),
|
||||
item("a", "settings", {
|
||||
id: "s",
|
||||
title: "Word Tools",
|
||||
properties: [{ key: "k", title: "Cancel", enum: ["Refresh"] }],
|
||||
}),
|
||||
item("a", "menus", { id: "m", menu: "tools", command: "Refresh" }),
|
||||
item("b", "commands", { id: "y", title: "Refresh" }),
|
||||
];
|
||||
const out = localizeContributions(items, { a: fr });
|
||||
expect(out[0]!.decl).toMatchObject({ title: "Actualiser", category: "Outils de mots" });
|
||||
expect(out[1]!.decl).toMatchObject({
|
||||
title: "Outils de mots",
|
||||
properties: [{ title: "Annuler", enum: ["Refresh"] }],
|
||||
});
|
||||
expect(out[2]).toBe(items[2]);
|
||||
expect(out[3]).toBe(items[3]);
|
||||
});
|
||||
|
||||
it("keeps the names of secrets and translates their labels", () => {
|
||||
const secret = item("a", "secrets", { id: "k", name: "Refresh", label: "Cancel", description: "Refresh" });
|
||||
expect(localizeContributions([secret], { a: fr })[0]!.decl).toMatchObject({
|
||||
name: "Refresh",
|
||||
label: "Annuler",
|
||||
description: "Actualiser",
|
||||
});
|
||||
});
|
||||
|
||||
it("gives a tools menu entry the translated title of its command", () => {
|
||||
const items = [
|
||||
item("a", "commands", { id: "x", title: "Refresh" }),
|
||||
item("a", "menus", { id: "m", menu: "tools", command: "x" }),
|
||||
];
|
||||
expect(localizeContributions(items, { a: fr })[1]!.decl).toMatchObject({ commandTitle: "Actualiser" });
|
||||
});
|
||||
|
||||
it("ignores catalog entries inherited from Object.prototype", () => {
|
||||
const items = [item("a", "commands", { id: "x", title: "constructor", category: "__proto__" })];
|
||||
expect(localizeContributions(items, { a: fr })[0]!.decl).toMatchObject({
|
||||
title: "constructor",
|
||||
category: "__proto__",
|
||||
});
|
||||
expect(translatorOf({ a: fr }, "constructor")("Refresh")).toBe("Refresh");
|
||||
expect(translatorOf({ a: fr }, "a")("toString")).toBe("toString");
|
||||
});
|
||||
|
||||
it("returns the declarations as they are without a catalog", () => {
|
||||
const items = [item("a", "commands", { id: "x", title: "Refresh" })];
|
||||
expect(localizeContributions(items, {})).toBe(items);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeView", () => {
|
||||
const t = translatorOf({ a: fr }, "a");
|
||||
const view = (body: View["body"]): View => ({
|
||||
toolbar: [{ id: "r", label: "Refresh", icon: null, enabled: true }],
|
||||
body,
|
||||
status: "Cancel",
|
||||
});
|
||||
|
||||
it("translates buttons, tree items, fields and status, and keeps unknown text", () => {
|
||||
const tree = localizeView(
|
||||
view({
|
||||
kind: "tree",
|
||||
value: [
|
||||
{ key: "1", parent: null, label: "Refresh", icon: null, detail: "other", expandable: false, expanded: false },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(tree.toolbar[0]!.label).toBe("Actualiser");
|
||||
expect(tree.status).toBe("Annuler");
|
||||
expect(tree.body).toMatchObject({ value: [{ label: "Actualiser", detail: "other" }] });
|
||||
|
||||
const form = localizeView(
|
||||
view({ kind: "form", value: [{ key: "k", label: "Cancel", value: { kind: "text", value: "Cancel" } }] }),
|
||||
t,
|
||||
);
|
||||
expect(form.body).toMatchObject({ value: [{ label: "Annuler", value: { kind: "text", value: "Cancel" } }] });
|
||||
});
|
||||
|
||||
it("leaves html bodies alone", () => {
|
||||
expect(localizeView(view({ kind: "html", value: "<p>Refresh</p>" }), t).body).toEqual({
|
||||
kind: "html",
|
||||
value: "<p>Refresh</p>",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Translation of a plugin's own text. A package's `locales/<tag>.json` maps
|
||||
* English source text to a translation; it is applied to the plugin's
|
||||
* declarations and view data, and only to those: the IDE's catalogs are not
|
||||
* touched and a plugin never sees another's. Text without an entry stays
|
||||
* English.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { UiContribution, View } from "@/bindings";
|
||||
import { LOCALES, type Locale } from "@/platform/i18n";
|
||||
import { pluginsBackend } from "./backend";
|
||||
|
||||
type Catalog = Record<string, string>;
|
||||
export type PluginTranslate = (text: string) => string;
|
||||
|
||||
export const usePluginCatalogs = create<{ catalogs: Record<string, Catalog> }>(() => ({ catalogs: {} }));
|
||||
|
||||
const LOCALES_POINT = "locales";
|
||||
|
||||
/** The catalog of `tags` that serves `locale`: the exact tag (`zh-CN`), then the language (`zh`), then a variant (`zh-TW`). */
|
||||
export function pickCatalogTag(tags: readonly string[], locale: string): string | undefined {
|
||||
const full = (LOCALES[locale as Locale]?.tag ?? locale).toLowerCase();
|
||||
const language = full.split("-")[0]!;
|
||||
const lower = tags.map((t) => t.toLowerCase());
|
||||
const at = lower.indexOf(full) >= 0 ? lower.indexOf(full) : lower.indexOf(language);
|
||||
if (at >= 0) return tags[at];
|
||||
return tags.find((t) => t.toLowerCase().split("-")[0] === language);
|
||||
}
|
||||
|
||||
let loadToken = 0;
|
||||
|
||||
/** Reads the catalogs of the plugins that ship one for `locale`; the others have none. */
|
||||
export async function loadPluginCatalogs(ui: readonly UiContribution[], locale: string): Promise<void> {
|
||||
const token = ++loadToken;
|
||||
const entries = await Promise.all(
|
||||
ui
|
||||
.filter((c) => c.point === LOCALES_POINT)
|
||||
.map(async (c): Promise<[string, Catalog] | null> => {
|
||||
const tags = ((c.decl as { tags?: string[] }).tags ?? []) as string[];
|
||||
const tag = pickCatalogTag(tags, locale);
|
||||
if (!tag) return null;
|
||||
try {
|
||||
const assets = await pluginsBackend().assets(c.pluginId, `locale/${tag}`);
|
||||
const text = assets.find(([id]) => id === `locale/${tag}`)?.[1];
|
||||
return text ? [c.pluginId, JSON.parse(text) as Catalog] : null;
|
||||
} catch (e) {
|
||||
console.warn(`plugin ${c.pluginId}: cannot load its ${tag} catalog`, e);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (token !== loadToken) return;
|
||||
const catalogs = Object.fromEntries(entries.filter((e) => e !== null));
|
||||
if (JSON.stringify(catalogs) !== JSON.stringify(usePluginCatalogs.getState().catalogs))
|
||||
usePluginCatalogs.setState({ catalogs });
|
||||
}
|
||||
|
||||
/** `map[key]` when `map` has the key itself, not through its prototype (`constructor`, `__proto__`). */
|
||||
function ownEntry<T>(map: Record<string, T> | undefined, key: string): T | undefined {
|
||||
return map && Object.hasOwn(map, key) ? map[key] : undefined;
|
||||
}
|
||||
|
||||
export function translatorOf(catalogs: Record<string, Catalog>, pluginId: string): PluginTranslate {
|
||||
const catalog = ownEntry(catalogs, pluginId);
|
||||
return catalog ? (text) => ownEntry(catalog, text) ?? text : (text) => text;
|
||||
}
|
||||
|
||||
/** `text` in the active language, for the plugin's own text; re-renders when the catalog changes. */
|
||||
export function usePluginT(pluginId: string): PluginTranslate {
|
||||
const catalog = usePluginCatalogs((s) => ownEntry(s.catalogs, pluginId));
|
||||
return useCallback((text) => (catalog ? (ownEntry(catalog, text) ?? text) : text), [catalog]);
|
||||
}
|
||||
|
||||
/** The keys of each point's declaration that are shown text; ids, names of secrets and option values are not. */
|
||||
const TEXT_KEYS: Record<string, readonly string[]> = {
|
||||
commands: ["title", "category"],
|
||||
views: ["title"],
|
||||
customEditors: ["title"],
|
||||
statusBar: ["text", "tooltip"],
|
||||
settings: ["title"],
|
||||
fileTemplates: ["name"],
|
||||
iconThemes: ["label"],
|
||||
themes: ["name"],
|
||||
secrets: ["label", "description"],
|
||||
};
|
||||
|
||||
function localizeDecl(decl: unknown, keys: readonly string[], t: PluginTranslate): unknown {
|
||||
if (!decl || typeof decl !== "object") return decl;
|
||||
const out: Record<string, unknown> = { ...(decl as Record<string, unknown>) };
|
||||
for (const key of keys) if (typeof out[key] === "string") out[key] = t(out[key]);
|
||||
if (Array.isArray(out.properties))
|
||||
out.properties = out.properties.map((p) => localizeDecl(p, ["title", "description"], t));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The declarations with their titles and labels translated by their plugin's catalog. */
|
||||
export function localizeContributions(
|
||||
items: readonly UiContribution[],
|
||||
catalogs: Record<string, Catalog>,
|
||||
): readonly UiContribution[] {
|
||||
if (Object.keys(catalogs).length === 0) return items;
|
||||
const localized = items.map((item) => {
|
||||
const keys = TEXT_KEYS[item.point];
|
||||
return keys && ownEntry(catalogs, item.pluginId)
|
||||
? { ...item, decl: localizeDecl(item.decl, keys, translatorOf(catalogs, item.pluginId)) }
|
||||
: item;
|
||||
});
|
||||
// A menu entry is labelled with its command's title, which changes with the language but not with the entry.
|
||||
const titles = new Map(
|
||||
localized
|
||||
.filter((c) => c.point === "commands")
|
||||
.map((c) => [`${c.pluginId}/${(c.decl as { id: string }).id}`, (c.decl as { title: string }).title]),
|
||||
);
|
||||
return localized.map((item) => {
|
||||
const title =
|
||||
item.point === "menus" ? titles.get(`${item.pluginId}/${(item.decl as { command: string }).command}`) : undefined;
|
||||
return title !== undefined && ownEntry(catalogs, item.pluginId)
|
||||
? { ...item, decl: { ...(item.decl as object), commandTitle: title } }
|
||||
: item;
|
||||
});
|
||||
}
|
||||
|
||||
/** A view with the buttons, labels, columns and messages translated; `html` bodies and cell data are left as they are. */
|
||||
export function localizeView(view: View, t: PluginTranslate): View {
|
||||
const body = view.body;
|
||||
const localized: View["body"] = (() => {
|
||||
switch (body.kind) {
|
||||
case "message":
|
||||
return { ...body, value: t(body.value) };
|
||||
case "tree":
|
||||
return {
|
||||
...body,
|
||||
value: body.value.map((i) => ({ ...i, label: t(i.label), detail: i.detail && t(i.detail) })),
|
||||
};
|
||||
case "list":
|
||||
return {
|
||||
...body,
|
||||
value: body.value.map((i) => ({ ...i, label: t(i.label), detail: i.detail && t(i.detail) })),
|
||||
};
|
||||
case "form":
|
||||
return { ...body, value: body.value.map((f) => ({ ...f, label: t(f.label) })) };
|
||||
case "table":
|
||||
return { ...body, value: { ...body.value, columns: body.value.columns.map(t) } };
|
||||
default:
|
||||
return body;
|
||||
}
|
||||
})();
|
||||
return {
|
||||
toolbar: view.toolbar.map((b) => ({ ...b, label: t(b.label) })),
|
||||
body: localized,
|
||||
status: view.status && t(view.status),
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { pickItems, promptInput } from "@/workbench/inputDialogs";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { usePluginT } from "./pluginText";
|
||||
import { bumpView, deliverViewMessage, pluginToolWindowId } from "./views/viewStore";
|
||||
import { runPluginCommand, usePlugins } from "./store";
|
||||
|
||||
@@ -81,16 +82,21 @@ function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) {
|
||||
const running = usePluginStatusItems((s) => s.items.get(itemKey));
|
||||
const declared = useDeclaredStatusItems((s) => s.items.get(itemKey));
|
||||
const entry = running ?? declared;
|
||||
const t = usePluginT(entry?.pluginId ?? "");
|
||||
if (!entry) return null;
|
||||
const { pluginId, item } = entry;
|
||||
const command = item.command;
|
||||
// Declared items were translated with their declaration.
|
||||
const translate = running ? t : (text: string) => text;
|
||||
const tooltip = item.tooltip ? translate(item.tooltip) : undefined;
|
||||
const text = translate(item.text);
|
||||
return (
|
||||
<BarButton
|
||||
tooltip={item.tooltip ?? undefined}
|
||||
aria-label={item.tooltip ?? item.text}
|
||||
tooltip={tooltip}
|
||||
aria-label={tooltip ?? text}
|
||||
onClick={command ? () => void runPluginCommand(pluginId, command) : undefined}
|
||||
>
|
||||
{item.text}
|
||||
{text}
|
||||
</BarButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ interface MenuDecl {
|
||||
view?: string | null;
|
||||
command: string;
|
||||
when?: string | null;
|
||||
/** The command's title in the plugin's language, when it ships one. */
|
||||
commandTitle?: string;
|
||||
}
|
||||
|
||||
const CONTEXT_MENUS: Record<string, ContextMenuId> = {
|
||||
@@ -126,7 +128,7 @@ export function pluginViewItemMenuItems(pluginId: string, view: string, key: str
|
||||
function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void {
|
||||
if (decl.menu === "tools") {
|
||||
const action = pluginActionId(pluginId, decl.command);
|
||||
const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
|
||||
const title = decl.commandTitle ?? getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
|
||||
return registerMenuSlotItem("pluginTools", { action, label: title });
|
||||
}
|
||||
if (decl.menu === "commit.message") {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { pluginsBackend } from "../backend";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { localizeView, usePluginT } from "../pluginText";
|
||||
import { onViewMessage, useViewRevision } from "./viewStore";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
@@ -54,7 +55,9 @@ const OVERSCAN = 50;
|
||||
const ROW_HEIGHT = 24;
|
||||
|
||||
export function PluginView({ pluginId, view, doc = null, revision = 0, label, itemMenu }: PluginViewProps) {
|
||||
const [content, setContent] = useState<View | null>(null);
|
||||
const [rendered, setContent] = useState<View | null>(null);
|
||||
const t = usePluginT(pluginId);
|
||||
const content = useMemo(() => rendered && localizeView(rendered, t), [rendered, t]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewport, setViewport] = useState<Viewport | null>(null);
|
||||
const pluginRevision = useViewRevision(pluginId, view);
|
||||
|
||||
Reference in new issue
Заблокувати користувача