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"],