From 90cca594620f25ad991df45e8742ab986dc5c545 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:44:03 +0800 Subject: [PATCH] fix(plugins): show plugin text as written instead of through the IDE catalog (#1999) A plugin title, label or message equal to IDE text ("Pick", "Log", "Refresh") was translated by the IDE catalog. Primitives now translate given text through useGivenTx, which returns it as written below VerbatimTextContext (plugin views, service panes, status items, prompts); plugin commands, tool windows, menu labels and notifications carry a verbatim flag. Command categories and the IDE's own words stay translated. --- src/features/app/menuBridge.ts | 4 +- .../plugins/__tests__/plugins.test.tsx | 2 +- .../plugins/__tests__/verbatimText.test.tsx | 80 ++++++++++++++++++ src/features/plugins/__tests__/views.test.tsx | 1 + src/features/plugins/bridge.ts | 8 +- src/features/plugins/points.tsx | 1 + src/features/plugins/runtimeUi.tsx | 24 +++--- src/features/plugins/uiPoints.tsx | 7 +- src/features/plugins/views/PluginView.tsx | 13 ++- src/features/plugins/views/ServicePane.tsx | 14 +++- src/features/search/everywhere/model.ts | 13 ++- src/platform/actions.ts | 4 +- src/platform/i18n.ts | 24 +++++- src/ui/BarButton.tsx | 4 +- src/ui/Button.tsx | 8 +- src/ui/Checkbox.tsx | 6 +- src/ui/Dialog.tsx | 14 ++-- src/ui/Feedback.tsx | 14 ++-- src/ui/Input.tsx | 6 +- src/ui/Select.tsx | 25 +++--- src/ui/T.tsx | 7 +- src/ui/Tabs.tsx | 13 +-- src/ui/Tooltip.tsx | 6 +- src/ui/Tree.tsx | 5 +- src/ui/index.ts | 2 +- src/workbench/Notifications.tsx | 81 ++++++++++++------- src/workbench/contributions.ts | 6 ++ src/workbench/inputDialogs.tsx | 22 +++-- src/workbench/messages.tsx | 31 ++++--- src/workbench/notificationStore.ts | 3 + src/workbench/toolwindows/ToolWindows.tsx | 28 ++++--- src/workbench/toolwindows/actions.ts | 1 + 32 files changed, 341 insertions(+), 136 deletions(-) create mode 100644 src/features/plugins/__tests__/verbatimText.test.tsx diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 2f8f4e6f4..0af02a2d3 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -60,7 +60,7 @@ export function buildMenuConfig(os: HostOs): MenuConfig { /** View › Tool Windows (every registered window, by title) and Window › Tool Window Layouts. */ export function dynamicMenuItems(): Record { const toolWindows = [...useContributions.getState().toolWindows.values()] - .map((tw) => ({ action: `toolwindow.${tw.id}`, arg: null, label: tx(tw.title) ?? tw.title })) + .map((tw) => ({ action: `toolwindow.${tw.id}`, arg: null, label: tw.verbatim ? tw.title : tx(tw.title) })) .sort((a, b) => a.label.localeCompare(b.label)); const layouts = useToolWindowLayouts .getState() @@ -69,7 +69,7 @@ export function dynamicMenuItems(): Record { Object.entries(useMenuSlotItems.getState().slots).map(([slot, items]) => [ slot, [...items] - .map((i) => ({ action: i.action, arg: i.arg ?? null, label: tx(i.label) ?? i.label })) + .map((i) => ({ action: i.action, arg: i.arg ?? null, label: i.verbatim ? i.label : tx(i.label) })) .sort((a, b) => a.label.localeCompare(b.label)), ]), ); diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 871e857a8..096c45140 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -493,7 +493,7 @@ describe("bridge events", () => { Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); const open = vi.spyOn(window, "open").mockImplementation(() => null); handlePluginEvent({ type: "notification", pluginId: "p", level: "info", title: "Done", body: "3 words" }); - expect(notes).toEqual([{ group: "plugins", level: "info", title: "Done", body: "3 words" }]); + expect(notes).toEqual([{ group: "plugins", level: "info", title: "Done", body: "3 words", verbatim: true }]); handlePluginEvent({ type: "copyToClipboard", pluginId: "p", text: "https://github.com/o/r" }); expect(writeText).toHaveBeenCalledWith("https://github.com/o/r"); handlePluginEvent({ type: "openUrl", pluginId: "p", url: "https://github.com/o/r/blob/main/a.rs" }); diff --git a/src/features/plugins/__tests__/verbatimText.test.tsx b/src/features/plugins/__tests__/verbatimText.test.tsx new file mode 100644 index 000000000..cb1dbe1dd --- /dev/null +++ b/src/features/plugins/__tests__/verbatimText.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import type { ServiceTab } from "@/bindings"; +import { getAction, registerAction } from "@/platform/actions"; +import { setLocale, tx, VerbatimTextContext } from "@/platform/i18n"; +import { Button, EmptyState } from "@/ui"; +import { InputDialogsHost, pickItems } from "@/workbench/inputDialogs"; +import { confirm, MessagesHost } from "@/workbench/messages"; +import { createMemoryPluginsBackend, setPluginsBackend } from "../backend"; +import { ServicePane } from "../views/ServicePane"; + +// "Pick", "Refresh" and "Log" are IDE text too ("拣选", "刷新", "日志"); a plugin means its own. +beforeEach(async () => { + await setLocale("zh"); +}); + +afterEach(async () => { + cleanup(); + setPluginsBackend(null); + await setLocale("en"); +}); + +describe("plugin text", () => { + it("primitives show given text as written below plugin UI, their own words translated", () => { + render( + <> + + + + + + , + ); + expect(screen.getByRole("button", { name: "刷新" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy(); + expect(screen.getByText("Log")).toBeTruthy(); + }); + + it("keeps a plugin command's title, translating its IDE category", () => { + const disposers = [ + registerAction({ id: "test.ide.pick", title: "Pick", category: "Tools", run: () => {} }), + registerAction({ id: "test.plugin.pick", title: "Pick", category: "Tools", verbatim: true, run: () => {} }), + ]; + expect(getAction("test.ide.pick")?.title).toBe(tx("Pick")); + expect(getAction("test.plugin.pick")?.title).toBe("Pick"); + expect(getAction("test.plugin.pick")?.category).toBe(tx("Tools")); + for (const d of disposers) d(); + }); + + it("shows a plugin's service tabs as written", async () => { + const tabs: ServiceTab[] = [{ id: "log", label: "Log", body: { kind: "text", value: "a: 1" } }]; + setPluginsBackend({ ...createMemoryPluginsBackend([]), serviceDetail: async () => tabs }); + render(); + expect(await screen.findByRole("tab", { name: "Log" })).toBeTruthy(); + }); + + it("asks a plugin's questions as written, with the IDE's own buttons translated", async () => { + render( + <> + + + , + ); + act(() => { + void pickItems({ title: "Pick", items: [{ key: "a", label: "Log" }], multiple: false, verbatim: true }); + }); + expect(await screen.findByRole("dialog", { name: "Pick" })).toBeTruthy(); + expect(screen.getByText("Log")).toBeTruthy(); + expect(screen.getByRole("button", { name: "确定" })).toBeTruthy(); + cleanup(); + + render(); + act(() => { + void confirm({ title: "Pick", message: "Refresh", ok: tx("OK"), verbatim: true }); + }); + expect(await screen.findByRole("dialog", { name: "Pick" })).toBeTruthy(); + expect(screen.getByText("Refresh")).toBeTruthy(); + expect(screen.getByRole("button", { name: "确定" })).toBeTruthy(); + }); +}); diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index 05c57c786..e6a8a2247 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -296,6 +296,7 @@ describe("plugin menus", () => { { kind: "item", label: "Arthas", + verbatim: true, submenu: [ { kind: "action", action: "plugin.p.watch", arg: undefined }, { kind: "action", action: "plugin.p.trace", arg: undefined }, diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index 075c8a7a8..ce80d444b 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -278,7 +278,13 @@ export function handlePluginEvent(e: PluginEvent) { break; case "notification": { const name = usePlugins.getState().plugins.find((p) => p.id === e.pluginId)?.name ?? e.pluginId; - notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined }); + notifications().notify({ + group: "plugins", + level: e.level, + title: e.title || name, + body: e.body || undefined, + verbatim: true, + }); break; } case "themeChanged": diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx index a2820ff90..aff27805a 100644 --- a/src/features/plugins/points.tsx +++ b/src/features/plugins/points.tsx @@ -153,6 +153,7 @@ export function defineBuiltinUiPoints(): () => void { category: decl.category ?? plugin.name, icon: "package", when: decl.when ?? undefined, + verbatim: true, keywords: ["plugin", plugin.name, pluginId], description: tx`From plugin ${plugin.name}`, run: async ({ arg }) => { diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index d3ce5194f..6e6c75f65 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -20,6 +20,7 @@ import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; import { clearContext, setContext, type ContextValue } from "@/platform/context"; import { onIdeEvent } from "@/platform/ipc"; import { openExternalUrl } from "@/platform/openUrl"; +import { tx, VerbatimTextContext } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import { shortcutLabel } from "@/platform/keymap"; import { BarButton } from "@/ui/BarButton"; @@ -91,13 +92,15 @@ function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) { const tooltip = item.tooltip ? translate(item.tooltip) : undefined; const text = translate(item.text); return ( - void runPluginCommand(pluginId, command) : undefined} - > - {text} - + + void runPluginCommand(pluginId, command) : undefined} + > + {text} + + ); } @@ -320,7 +323,7 @@ const openPrompts = new Map(); async function askUser(prompt: Prompt, signal: AbortSignal): Promise { switch (prompt.kind) { case "quickPick": - return pickItems({ title: prompt.title, items: prompt.items, multiple: prompt.multiple, signal }); + return pickItems({ title: prompt.title, items: prompt.items, multiple: prompt.multiple, signal, verbatim: true }); case "input": { const text = await promptInput({ title: prompt.prompt, @@ -329,11 +332,14 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise voi return registerToolWindow({ id: pluginToolWindowId(pluginId, decl.id), title: decl.title, + verbatim: true, icon, anchor: decl.location, order: 900, @@ -599,6 +601,7 @@ export function startViewUi(openInput: (input: ReturnType ({ label: e.decl.title, + verbatim: true, run: () => openInput(customEditorInput(e.pluginId, e.decl, path)), })), }); diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index 79a0fa9e1..b92909f5b 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -17,7 +17,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings"; -import { tx } from "@/platform/i18n"; +import { tx, VerbatimTextContext } from "@/platform/i18n"; import { Checkbox, EmptyState, @@ -54,7 +54,16 @@ export interface PluginViewProps { const OVERSCAN = 50; const ROW_HEIGHT = 24; -export function PluginView({ pluginId, view, doc = null, revision = 0, label, itemMenu }: PluginViewProps) { +/** Plugin text is shown as written: the plugin translates it through its own catalog. */ +export function PluginView(props: PluginViewProps) { + return ( + + + + ); +} + +function PluginViewContent({ pluginId, view, doc = null, revision = 0, label, itemMenu }: PluginViewProps) { const [rendered, setContent] = useState(null); const t = usePluginT(pluginId); const content = useMemo(() => rendered && localizeView(rendered, t), [rendered, t]); diff --git a/src/features/plugins/views/ServicePane.tsx b/src/features/plugins/views/ServicePane.tsx index 83225ec74..0044264ad 100644 --- a/src/features/plugins/views/ServicePane.tsx +++ b/src/features/plugins/views/ServicePane.tsx @@ -4,6 +4,7 @@ */ import { useEffect, useState } from "react"; import type { ServiceTab } from "@/bindings"; +import { tx, VerbatimTextContext } from "@/platform/i18n"; import { EmptyState, LogPane, Spinner, Tabs } from "@/ui"; import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { pluginsBackend } from "../backend"; @@ -16,7 +17,16 @@ interface ServicePaneProps { itemKey: string; } -export function ServicePane({ pluginId, view, itemKey }: ServicePaneProps) { +/** Plugin text is shown as written: the plugin translates it through its own catalog. */ +export function ServicePane(props: ServicePaneProps) { + return ( + + + + ); +} + +function ServicePaneContent({ pluginId, view, itemKey }: ServicePaneProps) { // `views.refresh()` asks for the pane again. const revision = useViewRevision(pluginId, view); const [tabs, setTabs] = useState(null); @@ -43,7 +53,7 @@ export function ServicePane({ pluginId, view, itemKey }: ServicePaneProps) { if (error) return ; if (!tabs) return ; const active = tabs.find((t) => t.id === picked) ?? tabs[0]; - if (!active) return ; + if (!active) return ; return (
([...actions, ...settings]); } +/** An action's title in the UI language; a plugin's stays as written. */ +function shown(a: Action, text: string): string { + return a.verbatim ? text : tx(text); +} + export function actionItem(a: Action, enabled: boolean): ActionItem { return { kind: "action", key: `a:${a.id}`, id: a.id, - title: tx(a.title), - category: a.category ? tx(a.category) : a.category, + title: shown(a, a.title), + category: a.category && tx(a.category), icon: a.icon, enabled, indices: [], diff --git a/src/platform/actions.ts b/src/platform/actions.ts index 2b561ad5e..dd5777a8f 100644 --- a/src/platform/actions.ts +++ b/src/platform/actions.ts @@ -43,6 +43,8 @@ export interface Action { keyWhen?: string; /** Extra search terms for the Actions tab. */ keywords?: string[]; + /** The title comes from outside the IDE (a plugin) and is shown as written; the category still names an IDE group. */ + verbatim?: boolean; /** Hidden from Search Everywhere (internal / parameterised actions). */ hidden?: boolean; /** @@ -75,7 +77,7 @@ function localized(key: string, text: string | undefined): string | undefined { } function localize(action: Action): Action { - const title = localized(`action.${action.id}`, action.title); + const title = action.verbatim ? undefined : localized(`action.${action.id}`, action.title); const description = localized(`action.${action.id}.description`, action.description); const category = localized(`action.category.${action.category}`, action.category); if (!title && !description && !category) return action; diff --git a/src/platform/i18n.ts b/src/platform/i18n.ts index 42ec54c3e..159c2409a 100644 --- a/src/platform/i18n.ts +++ b/src/platform/i18n.ts @@ -24,7 +24,7 @@ * - `{n, number}`. * Branches nest. Unknown parameters are left as written. */ -import { useCallback, useMemo } from "react"; +import { createContext, useCallback, useContext, useMemo } from "react"; import { create } from "zustand"; import en from "./locales/en.json"; @@ -196,6 +196,28 @@ export function useTx(): typeof tx { }, [texts]); } +/** + * True below UI that shows text from outside the IDE as written: a plugin's + * titles, labels and messages. Its own translations come from its package; the + * IDE's catalog would turn a plugin's "Pick" or "Log" into the IDE's meaning. + */ +export const VerbatimTextContext = createContext(false); + +const asWritten = ((text: string | TemplateStringsArray | undefined, ...values: unknown[]) => + text === undefined || typeof text === "string" + ? text + : text.reduce((out, s, i) => out + String(values[i - 1]) + s)) as typeof tx; + +/** + * `useTx` for text a component was given (labels, titles, options): as written + * below a {@link VerbatimTextContext}, translated elsewhere. A component's own + * words keep `tx`. + */ +export function useGivenTx(): typeof tx { + const bound = useTx(); + return useContext(VerbatimTextContext) ? asWritten : bound; +} + function translateSource(texts: Catalog, text: string | TemplateStringsArray | undefined, values: unknown[]) { // A tag without `${}` spells out its whole text, which is looked up as written. if (text !== undefined && typeof text !== "string" && !values.length) text = text[0]!; diff --git a/src/ui/BarButton.tsx b/src/ui/BarButton.tsx index 0843281ec..a266d1c19 100644 --- a/src/ui/BarButton.tsx +++ b/src/ui/BarButton.tsx @@ -1,5 +1,6 @@ import { forwardRef, type ButtonHTMLAttributes } from "react"; import clsx from "clsx"; +import { useGivenTx } from "@/platform/i18n"; import { tooltipAttrs } from "./Tooltip"; import styles from "./BarButton.module.css"; @@ -18,12 +19,13 @@ export const BarButton = forwardRef(function { variant = "status", mono, tooltip, shortcut, emphasis, className, type = "button", children, ...rest }, ref, ) { + const given = useGivenTx(); return ( ))}
@@ -225,8 +225,8 @@ function DialogInner({ }: DialogProps) { const ref = useRef(null); const platform = useDialogPlatform(platformOverride); - const tx = useTx(); - if (typeof title === "string") title = tx(title); + const given = useGivenTx(); + if (typeof title === "string") title = given(title); // Choosers and untitled dialogs (Recent Files, About) are IDEA popups. const popup = position === "top" || (title == null && !icon); const onCloseRef = useRef(onClose); @@ -438,7 +438,7 @@ function DialogInner({
{title != null &&
{title}
}
- {typeof children === "string" ? tx(children) : children} + {typeof children === "string" ? given(children) : children}
{footerStart &&
{footerStart}
}
diff --git a/src/ui/Feedback.tsx b/src/ui/Feedback.tsx index 05add2b25..f44b4b3e4 100644 --- a/src/ui/Feedback.tsx +++ b/src/ui/Feedback.tsx @@ -2,7 +2,8 @@ import type { ReactNode } from "react"; import clsx from "clsx"; import { Icon, type IconName } from "./icons"; import { IconButton } from "./Button"; -import { T } from "./T"; +import { Given } from "./T"; +import { useTx } from "@/platform/i18n"; import { selectableText } from "./selectableText"; import styles from "./Feedback.module.css"; @@ -89,7 +90,7 @@ export interface EmptyStateProps { export function EmptyState({ title, children, actions, className }: EmptyStateProps) { return (
- {title &&
{typeof title === "string" ? {title} : title}
} + {title &&
{typeof title === "string" ? {title} : title}
} {children &&
{children}
} {actions &&
{actions}
}
@@ -125,6 +126,7 @@ export function Toast({ onPointerEnter, onPointerLeave, }: ToastProps) { + const tx = useTx(); return (
- {typeof title === "string" ? {title} : title} + {typeof title === "string" ? {title} : title} {meta && {meta}}
- {body &&
{typeof body === "string" ? {body} : body}
} + {body &&
{typeof body === "string" ? {body} : body}
} {actions && actions.length > 0 && (
{actions.map((a) => ( ))}
@@ -155,7 +157,7 @@ export function Toast({ {onClose && ( , "size"> { @@ -38,7 +38,7 @@ export const Input = forwardRef(function Input( }, ref, ) { - const tx = useTx(); + const tx = useGivenTx(); return ( (functi }, ref, ) { - const tx = useTx(); + const tx = useGivenTx(); const inner = useRef(null); const setRef = (el: HTMLInputElement | HTMLTextAreaElement | null) => { inner.current = el; diff --git a/src/ui/Select.tsx b/src/ui/Select.tsx index 2cb36e5d0..456b5e693 100644 --- a/src/ui/Select.tsx +++ b/src/ui/Select.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { Icon, type IconName } from "./icons"; import { Popup } from "./Popup"; import { List, listNavigate } from "./List"; -import { useTx } from "@/platform/i18n"; +import { tx, useGivenTx } from "@/platform/i18n"; import { SearchField } from "./Input"; import styles from "./Select.module.css"; @@ -64,7 +64,7 @@ export function Select({ onPreview, ...aria }: SelectProps) { - const tx = useTx(); + const given = useGivenTx(); const [open, setOpen] = useState(false); const triggerRef = useRef(null); const current = options.find((o) => !o.section && o.value === value); @@ -79,7 +79,8 @@ export function Select({ const q = query.trim().toLowerCase(); const tokens = q.split(/\s+/).filter(Boolean); const matches = (o: SelectOption) => { - const haystack = `${tx(o.label)} ${o.detail != null ? tx(o.detail) : ""} ${o.keywords ?? ""}`.toLowerCase(); + const haystack = + `${given(o.label)} ${o.detail != null ? given(o.detail) : ""} ${o.keywords ?? ""}`.toLowerCase(); return tokens.every((tok) => containsOrFuzzy(haystack, tok)); }; // Keep section headers visible as long as at least one of their @@ -164,7 +165,7 @@ export function Select({ const n = filtered.length; for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) { const i = (activeIndex + s) % n; - if (!unavailable(filtered[i]!) && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) { + if (!unavailable(filtered[i]!) && given(filtered[i]!.label).toLowerCase().startsWith(prefix)) { setActive(i); break; } @@ -179,7 +180,7 @@ export function Select({ type="button" aria-haspopup="listbox" aria-expanded={open} - aria-label={tx(aria["aria-label"])} + aria-label={given(aria["aria-label"])} disabled={disabled} className={clsx(styles.trigger, styles[variant], className)} onClick={() => (open ? setOpen(false) : openPopup())} @@ -196,7 +197,7 @@ export function Select({ <> {current?.icon && } - {tx(current?.label ?? placeholder)} + {given(current?.label ?? placeholder)} )} @@ -238,8 +239,8 @@ export function Select({
{o.icon && } - {tx(o.label)} - {o.detail && {tx(o.detail)}} + {given(o.label)} + {o.detail && {given(o.detail)}}
))}
@@ -247,7 +248,7 @@ export function Select({ items={filtered} getKey={(o) => `${o.section ? "section" : "option"}:${o.value}`} isSection={isSection} - renderSection={(o) => {tx(o.label)}} + renderSection={(o) => {given(o.label)}} activeIndex={activeIndex} onActiveChange={(i) => { if (!unavailable(filtered[i]!)) setActive(i); @@ -256,7 +257,7 @@ export function Select({ focusable={false} hoverSelects rowHeight={24} - aria-label={tx(aria["aria-label"])} + aria-label={given(aria["aria-label"])} className={styles.list} renderItem={(o) => ( ({ > {o.value === value && } {o.icon && } - {tx(o.label)} + {given(o.label)} {o.detail && ( - {tx(o.detail)} + {given(o.detail)} )} diff --git a/src/ui/T.tsx b/src/ui/T.tsx index acf038249..23f63564c 100644 --- a/src/ui/T.tsx +++ b/src/ui/T.tsx @@ -1,6 +1,11 @@ -import { useTx } from "@/platform/i18n"; +import { useGivenTx, useTx } from "@/platform/i18n"; /** English UI text, translated through the source-text catalog; re-renders on locale change. */ export function T({ children }: { children: string }) { return useTx()(children); } + +/** Text a component was given: translated like `T`, shown as written below plugin UI. */ +export function Given({ children }: { children: string }) { + return useGivenTx()(children); +} diff --git a/src/ui/Tabs.tsx b/src/ui/Tabs.tsx index f873734a8..6f1f5bddb 100644 --- a/src/ui/Tabs.tsx +++ b/src/ui/Tabs.tsx @@ -22,9 +22,9 @@ import { IconButton } from "./Button"; import { Menu, type MenuItem } from "./Menu"; import { tooltipAttrs } from "./Tooltip"; import { isOutsideWindow, registerDropTarget, startPointerDrag } from "./pointerDrag"; -import { T } from "./T"; +import { Given } from "./T"; import styles from "./Tabs.module.css"; -import { tx } from "@/platform/i18n"; +import { tx, useGivenTx } from "@/platform/i18n"; export interface TabItem { id: string; @@ -152,6 +152,7 @@ export function Tabs({ ...aria }: TabsProps) { const vertical = orientation === "vertical"; + const given = useGivenTx(); const scrollRef = useRef(null); const tabRefs = useRef(new Map()); const [overflow, setOverflow] = useState(false); @@ -283,7 +284,7 @@ export function Tabs({ const menuItems: MenuItem[] = items.map((t) => ({ id: t.id, - label: tx(t.text ?? (typeof t.label === "string" ? t.label : t.id)), + label: given(t.text ?? (typeof t.label === "string" ? t.label : t.id)), icon: t.icon, checked: t.id === activeId ? true : undefined, run: () => onSelect(t.id), @@ -316,7 +317,7 @@ export function Tabs({
{ @@ -411,11 +412,11 @@ export function Tabs({ }} onDoubleClick={onDoubleClick ? () => onDoubleClick(item.id) : undefined} onKeyDown={(e) => onKeyDown(e, index, item)} - {...tooltipAttrs(item.tooltip)} + {...tooltipAttrs(given(item.tooltip), undefined, true)} > {item.icon && } - {typeof item.label === "string" ? {item.label} : item.label} + {typeof item.label === "string" ? {item.label} : item.label} {item.trailing} {item.modified && !closable && } diff --git a/src/ui/Tooltip.tsx b/src/ui/Tooltip.tsx index 0b9702208..fbf7dd75e 100644 --- a/src/ui/Tooltip.tsx +++ b/src/ui/Tooltip.tsx @@ -12,10 +12,12 @@ import { tx } from "@/platform/i18n"; export const TOOLTIP_DELAY = 600; const WARM_MS = 400; -export function tooltipAttrs(text: string | undefined, shortcut?: string): Record { +/** `verbatim`: `text` is shown as written (already translated, or a plugin's own text). */ +export function tooltipAttrs(text: string | undefined, shortcut?: string, verbatim?: boolean): Record { if (!text) return {}; const attrs: Record = { "data-tooltip": text }; if (shortcut) attrs["data-tooltip-shortcut"] = shortcut; + if (verbatim) attrs["data-tooltip-verbatim"] = ""; return attrs; } @@ -46,7 +48,7 @@ export function TooltipHost() { }; const show = (el: HTMLElement) => { - const text = tx(el.dataset.tooltip); + const text = el.dataset.tooltipVerbatim === undefined ? tx(el.dataset.tooltip) : el.dataset.tooltip; if (!text || !el.isConnected) return; visible = true; anchorRef.current = el; diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index a89ff4b4b..f8ab23703 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -31,7 +31,7 @@ import { acquirePopup } from "./popupState"; import styles from "./Tree.module.css"; import { T } from "./T"; import { setContext } from "@/platform/context"; -import { tx } from "@/platform/i18n"; +import { useGivenTx } from "@/platform/i18n"; import { useTreeReorder, type TreeReorder } from "./treeReorder"; import { useTreeDirectoryContext } from "./treeDirectoryContext"; import { useIconRowHeight } from "./iconAppearance"; @@ -233,6 +233,7 @@ function useControllableSet(value: ReadonlySet | undefined, initial: Ite } export function Tree(props: TreeProps) { + const given = useGivenTx(); const { roots, getKey, @@ -627,7 +628,7 @@ export function Tree(props: TreeProps) {
v.index === leadIndex) diff --git a/src/ui/index.ts b/src/ui/index.ts index b8773d1ac..bd5968581 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -34,7 +34,7 @@ export { badgeKind } from "./symbolKind"; export { acquirePopup, isPopupOpen } from "./popupState"; export * from "./BarButton"; export { lazyComponent, lazyWhen, prefetchLazyComponents } from "./lazy"; -export { T } from "./T"; +export { Given, T } from "./T"; export { selectableText, copyMenuFor, copyText } from "./selectableText"; export { useSelectionBoundary } from "./useSelectionBoundary"; export { useSanitizedField } from "./useSanitizedField"; diff --git a/src/workbench/Notifications.tsx b/src/workbench/Notifications.tsx index 4622e2ee0..61003883f 100644 --- a/src/workbench/Notifications.tsx +++ b/src/workbench/Notifications.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { useTxTemplate } from "@/platform/i18n"; +import { useTxTemplate, VerbatimTextContext } from "@/platform/i18n"; import { EmptyState, IconButton, Toast, T } from "@/ui"; import { balloonTimeout, @@ -49,11 +49,24 @@ export function notificationActions(n: IdeNotification, wrap: (run: () => void) return [...own, { label: DONT_SHOW_AGAIN, run: wrap(() => void suppressGroup(n.group).catch(() => {})) }]; } +/** Title and action labels in the UI language; a plugin's own text stays as written. */ +function useShownText() { + const txt = useTxTemplate(); + return (n: IdeNotification) => { + const given = (s: string) => (n.verbatim ? s : txt(s)); + const own = n.actions?.length ?? 0; + return { + title: given(n.title), + actions: notificationActions(n).map((a, i) => ({ ...a, label: i < own ? given(a.label) : txt(a.label) })), + }; + }; +} + function Balloon({ n }: { n: IdeNotification }) { const [hover, setHover] = useState(false); const active = useWindowActive(); const remaining = useRef(null); - const txt = useTxTemplate(); + const shown = useShownText()(n); const ms = balloonTimeout(n); // IDEA's smart fade-out: the clock runs only while the window is active and the balloon not hovered. useEffect(() => { @@ -67,22 +80,24 @@ function Balloon({ n }: { n: IdeNotification }) { }; }, [n.id, ms, hover, active]); return ( - ({ - label: txt(a.label), - run: () => { - dismissBalloon(n.id); - a.run(); - }, - }))} - onClose={() => dismissBalloon(n.id)} - onPointerEnter={() => setHover(true)} - onPointerLeave={() => setHover(false)} - /> + + ({ + label: a.label, + run: () => { + dismissBalloon(n.id); + a.run(); + }, + }))} + onClose={() => dismissBalloon(n.id)} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + /> + ); } @@ -112,7 +127,7 @@ function useListVisible() { /** The Notifications tool window (right stripe, as in IDEA): history, newest first. */ export function NotificationsToolWindow() { - const txt = useTxTemplate(); + const shownText = useShownText(); const all = useNotifications((s) => s.items); const items = all.filter((n) => n.logged); const visible = useListVisible(); @@ -127,18 +142,22 @@ export function NotificationsToolWindow() { No notifications ) : ( - items.map((n) => ( - ({ ...a, label: txt(a.label) }))} - onClose={() => removeNotification(n.id)} - /> - )) + items.map((n) => { + const shown = shownText(n); + return ( + + removeNotification(n.id)} + /> + + ); + }) )}
); diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index a7f3019c6..d2275d351 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -29,6 +29,8 @@ export type ToolWindowAnchor = "left" | "right" | "bottom"; export interface ToolWindowContribution { id: string; title: string; + /** The title comes from outside the IDE (a plugin) and is shown as written. */ + verbatim?: boolean; /** Icon name from `ui/icons`. */ icon: string; anchor: ToolWindowAnchor; @@ -616,6 +618,8 @@ export interface NotificationService { sticky?: boolean; /** Replaces an earlier notification with the same key. */ key?: string; + /** Title, body and action labels come from outside the IDE (a plugin) and are shown as written. */ + verbatim?: boolean; /** * Notification group (Settings › Notifications): `system` (default), `lsp`, * `vcs`, `run`, `tools`, `plugins`, `search`, `updates`. @@ -1035,6 +1039,8 @@ export interface MenuSlotItem { action: string; arg?: string | null; label: string; + /** The label comes from outside the IDE (a plugin) and is shown as written. */ + verbatim?: boolean; /** Icon name, for toolbar slots. */ icon?: string; /** Shown only while this `when` clause holds (toolbar slots). */ diff --git a/src/workbench/inputDialogs.tsx b/src/workbench/inputDialogs.tsx index e48bc3d23..bfa83471b 100644 --- a/src/workbench/inputDialogs.tsx +++ b/src/workbench/inputDialogs.tsx @@ -9,8 +9,8 @@ */ import { useEffect, useRef, useState, type ReactNode } from "react"; import { create } from "zustand"; -import { t } from "@/platform/i18n"; -import { Checkbox, Dialog, Input, List, T } from "@/ui"; +import { t, tx, VerbatimTextContext } from "@/platform/i18n"; +import { Checkbox, Dialog, Given, Input, List, T } from "@/ui"; import styles from "./inputDialogs.module.css"; export interface PromptInputOptions { @@ -33,6 +33,8 @@ export interface PromptInputOptions { keepWhitespace?: boolean; /** A multi-line field: Enter adds a line, Cmd/Ctrl+Enter confirms. */ multiline?: boolean; + /** Title, text and labels come from outside the IDE (a plugin) and are shown as written. */ + verbatim?: boolean; } export interface ChooseItemOptions { @@ -48,6 +50,8 @@ export interface PickItemsOptions { multiple: boolean; /** Closes the dialog as cancelled when aborted. */ signal?: AbortSignal; + /** Title, text and labels come from outside the IDE (a plugin) and are shown as written. */ + verbatim?: boolean; } type Request = @@ -97,11 +101,15 @@ export function InputDialogsHost() { <> {stack.map((r) => r.kind === "prompt" ? ( - + + + ) : r.kind === "choose" ? ( ) : ( - + + + ), )} @@ -153,7 +161,7 @@ function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v width={420} buttons={[ { - label: options.okLabel ?? "OK", + label: options.okLabel ?? tx("OK"), role: "default", disabled: !!localError || pending, onClick: () => void submit(), @@ -170,7 +178,7 @@ function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v > {options.label && ( )} {options.multiline ? ( @@ -280,7 +288,7 @@ function PickDialog({ options, done }: { options: PickItemsOptions; done: (v: st width={420} buttons={[ { - label: "OK", + label: tx("OK"), role: "default", disabled: options.multiple ? false : activeKey === undefined, onClick: confirm, diff --git a/src/workbench/messages.tsx b/src/workbench/messages.tsx index c4e0e22f2..4a73d2198 100644 --- a/src/workbench/messages.tsx +++ b/src/workbench/messages.tsx @@ -10,7 +10,7 @@ */ import { useState, type ReactNode } from "react"; import { create } from "zustand"; -import { t } from "@/platform/i18n"; +import { t, VerbatimTextContext } from "@/platform/i18n"; import { Checkbox, Dialog, type DialogButton, type MessageIconKind } from "@/ui"; export interface MessageButton { @@ -36,6 +36,8 @@ export interface MessageRequest { /** Defaults to `question`. */ icon?: MessageIconKind; width?: number; + /** Title, text and labels come from outside the IDE (a plugin) and are shown as written. */ + verbatim?: boolean; } export interface MessageResult { @@ -83,6 +85,8 @@ export interface ConfirmOptions { checked?: boolean; /** Closes the dialog as cancelled when aborted. */ signal?: AbortSignal; + /** Title, text and labels come from outside the IDE (a plugin) and are shown as written. */ + verbatim?: boolean; } /** OK / Cancel question; resolves `null` when cancelled, else the checkbox state. */ @@ -95,6 +99,7 @@ export async function confirmChecked(opts: ConfirmOptions): Promise<{ checked: b checked: opts.checked, buttons: [{ id: "ok", label: opts.ok, primary: true }], signal: opts.signal, + verbatim: opts.verbatim, }); return r.button === "ok" ? { checked: r.checked } : null; } @@ -137,16 +142,18 @@ function MessageDialog({ message }: { message: Pending }) { })); const withCancel = message.cancel !== false && !message.buttons.some((b) => b.id === "cancel"); return ( - done("cancel")} - title={message.title} - icon={message.icon ?? "question"} - width={message.width ?? 460} - footerStart={message.checkbox && } - buttons={withCancel ? [...declared, { label: t("common.cancel"), role: "cancel" }] : declared} - > - {message.body} - + + done("cancel")} + title={message.title} + icon={message.icon ?? "question"} + width={message.width ?? 460} + footerStart={message.checkbox && } + buttons={withCancel ? [...declared, { label: t("common.cancel"), role: "cancel" }] : declared} + > + {message.body} + + ); } diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index 55da9efdc..b8a1017b0 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -17,6 +17,8 @@ export interface IdeNotification { actions?: Array<{ label: string; run: () => void }>; time: number; read: boolean; + /** Text shown as written (see `NotificationService.notify`). */ + verbatim?: boolean; /** Currently shown as a balloon. */ balloon: boolean; sticky: boolean; @@ -138,6 +140,7 @@ export function notify(n: Parameters[0]): number title: n.title, body: n.body, actions: n.actions, + verbatim: n.verbatim, time: Date.now(), read: false, balloon, diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index 7d8e9842c..eb425dc4f 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -17,7 +17,7 @@ import { create } from "zustand"; import { call, commands } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; -import { dragRequiresAlt, Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui"; +import { dragRequiresAlt, Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem } from "@/ui"; import { FOCUSABLE } from "@/ui/popupState"; import { TabStripRegistry } from "@/ui/Tabs"; import { @@ -51,6 +51,11 @@ import { contentTabRegistry } from "./content"; import styles from "./ToolWindows.module.css"; import { tx } from "@/platform/i18n"; +/** The window's title in the UI language; a plugin's stays as written. */ +function titleOf(tw: ToolWindowContribution): string { + return tw.verbatim ? tw.title : tx(tw.title); +} + function byOrder(a: ToolWindowContribution, b: ToolWindowContribution) { return (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title); } @@ -403,7 +408,7 @@ function StripeButton({ side === "right" && styles.stripeRight, dragged && styles.stripeDragged, )} - aria-label={tx(tw.title)} + aria-label={titleOf(tw)} aria-pressed={active} aria-keyshortcuts={shortcut} data-stripe-button={tw.id} @@ -415,7 +420,7 @@ function StripeButton({ e.preventDefault(); showContextMenu(e, stripeButtonMenu(tw, showName)); }} - {...tooltipAttrs(tw.title, shortcut)} + {...tooltipAttrs(titleOf(tw), shortcut, true)} > @@ -423,7 +428,7 @@ function StripeButton({ {showName && ( )} @@ -441,6 +446,7 @@ function StripeMore({ windows }: { windows: ToolWindowContribution[] }) { .map((w) => ({ id: w.id, label: w.title, + verbatim: w.verbatim, icon: w.icon, shortcut: shortcutLabel(`toolwindow.${w.id}`), run: () => showToolWindow(w.id, true), @@ -672,9 +678,7 @@ function HeaderRow({ aria-expanded={titleOpen} onClick={() => setTitleOpen((v) => !v)} > - - {titleMenu?.label ?? tw.title} - + {titleMenu ? tx(titleMenu.label) : titleOf(tw)} setTitleOpen(false)} anchor={titleRef} items={titleItems} - aria-label={titleMenu ? tx`${tw.title} views` : tx`${tw.title} view options`} + aria-label={titleMenu ? tx`${titleOf(tw)} views` : tx`${titleOf(tw)} view options`} /> ) : ( - - {tw.title} - + {titleOf(tw)} )} {children &&
{children}
}
@@ -721,7 +723,7 @@ function HeaderRow({ anchor={moreRef} items={more} placement="bottom-end" - aria-label={tx`${tw.title} options`} + aria-label={tx`${titleOf(tw)} options`} /> )} @@ -790,7 +792,7 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo ref={ref} className={styles.window} data-toolwindow={tw.id} - aria-label={tx(tw.title)} + aria-label={titleOf(tw)} hidden={hidden} tabIndex={-1} > diff --git a/src/workbench/toolwindows/actions.ts b/src/workbench/toolwindows/actions.ts index b24c2ca24..2ca328ae6 100644 --- a/src/workbench/toolwindows/actions.ts +++ b/src/workbench/toolwindows/actions.ts @@ -160,6 +160,7 @@ export function installToolWindowActions(): () => void { const dispose = registerAction({ id: `toolwindow.${id}`, title: tw.title, + verbatim: tw.verbatim, category: "Tool Windows", icon: tw.icon, keywords: ["tool window", "show", "hide"],