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]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 19:40:50 +08:00
1 джерело e0408cc7d7
коміт adc308eb99
50 files changed
+596 -40

No files matched your search

+6 -2
Переглянути файл
@@ -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>
+14 -5
Переглянути файл
@@ -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();
};
}
+118
Переглянути файл
@@ -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>",
});
});
});
+154
Переглянути файл
@@ -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),
};
}
+9 -3
Переглянути файл
@@ -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>
);
}
+3 -1
Переглянути файл
@@ -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") {
+4 -1
Переглянути файл
@@ -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);