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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-07 21:44:03 +08:00
1 джерело f96ca468a8
коміт 90cca59462
32 files changed
+341 -136

No files matched your search

+2 -2
Переглянути файл
@@ -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<string, DynamicItem[]> {
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<string, DynamicItem[]> {
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)),
]),
);
+1 -1
Переглянути файл
@@ -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" });
+80
Переглянути файл
@@ -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(
<>
<Button>Refresh</Button>
<VerbatimTextContext.Provider value={true}>
<Button>Refresh</Button>
<EmptyState title="Log" />
</VerbatimTextContext.Provider>
</>,
);
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(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
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(
<>
<InputDialogsHost />
<MessagesHost />
</>,
);
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(<MessagesHost />);
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();
});
});
+1
Переглянути файл
@@ -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 },
+7 -1
Переглянути файл
@@ -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":
+1
Переглянути файл
@@ -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 }) => {
+15 -9
Переглянути файл
@@ -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 (
<BarButton
tooltip={tooltip}
aria-label={tooltip ?? text}
onClick={command ? () => void runPluginCommand(pluginId, command) : undefined}
>
{text}
</BarButton>
<VerbatimTextContext.Provider value={true}>
<BarButton
tooltip={tooltip}
aria-label={tooltip ?? text}
onClick={command ? () => void runPluginCommand(pluginId, command) : undefined}
>
{text}
</BarButton>
</VerbatimTextContext.Provider>
);
}
@@ -320,7 +323,7 @@ const openPrompts = new Map<string, AbortController>();
async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] | null> {
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<string[] |
keepWhitespace: true,
multiline: prompt.multiline,
signal,
verbatim: true,
});
return text === null ? null : [text];
}
case "confirm":
return (await confirm({ title: prompt.title, message: prompt.message, ok: "OK", signal })) ? [] : null;
return (await confirm({ title: prompt.title, message: prompt.message, ok: tx("OK"), signal, verbatim: true }))
? []
: null;
}
}
+5 -2
Переглянути файл
@@ -116,7 +116,7 @@ function addGrouped(
if (!group) {
group = [];
groups.set(key, group);
out.push({ kind: "item", label: submenu, submenu: group });
out.push({ kind: "item", label: submenu, verbatim: true, submenu: group });
}
group.push(item);
}
@@ -165,7 +165,7 @@ function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () =
if (decl.menu === "tools") {
const action = pluginActionId(pluginId, decl.command);
const title = decl.commandTitle ?? getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
return registerMenuSlotItem("pluginTools", { action, label: title });
return registerMenuSlotItem("pluginTools", { action, label: title, verbatim: true });
}
if (decl.menu === "commit.message") {
const action = pluginActionId(pluginId, decl.command);
@@ -173,6 +173,7 @@ function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () =
return registerMenuSlotItem("commitMessage", {
action,
label,
verbatim: true,
icon: pluginIconName(pluginId, plugin.icon),
when: decl.when ?? null,
});
@@ -437,6 +438,7 @@ function registerView(pluginId: string, decl: ViewDecl, icon: string): () => 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<typeof customEditorInp
label: tx("Open With"),
submenu: editors.map((e) => ({
label: e.decl.title,
verbatim: true,
run: () => openInput(customEditorInput(e.pluginId, e.decl, path)),
})),
});
+11 -2
Переглянути файл
@@ -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 (
<VerbatimTextContext.Provider value={true}>
<PluginViewContent {...props} />
</VerbatimTextContext.Provider>
);
}
function PluginViewContent({ pluginId, view, doc = null, revision = 0, label, itemMenu }: PluginViewProps) {
const [rendered, setContent] = useState<View | null>(null);
const t = usePluginT(pluginId);
const content = useMemo(() => rendered && localizeView(rendered, t), [rendered, t]);
+12 -2
Переглянути файл
@@ -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 (
<VerbatimTextContext.Provider value={true}>
<ServicePaneContent {...props} />
</VerbatimTextContext.Provider>
);
}
function ServicePaneContent({ pluginId, view, itemKey }: ServicePaneProps) {
// `views.refresh(<view>)` asks for the pane again.
const revision = useViewRevision(pluginId, view);
const [tabs, setTabs] = useState<ServiceTab[] | null>(null);
@@ -43,7 +53,7 @@ export function ServicePane({ pluginId, view, itemKey }: ServicePaneProps) {
if (error) return <EmptyState title={error} />;
if (!tabs) return <Spinner />;
const active = tabs.find((t) => t.id === picked) ?? tabs[0];
if (!active) return <EmptyState title="Nothing to show" />;
if (!active) return <EmptyState title={tx("Nothing to show")} />;
return (
<div className={styles.pane}>
<Tabs
+9 -4
Переглянути файл
@@ -261,7 +261,7 @@ export function matchActions(
if (!enabled && opts.includeDisabled === false) continue;
let score: number;
let indices: number[] = [];
const translated = tx(a.title);
const translated = shown(a, a.title);
const title = matchText(translated, q, { wordAligned: true });
// The English title still matches; its indices don't apply to the translated text.
const english = title || translated === a.title ? undefined : matchText(a.title, q, { wordAligned: true });
@@ -287,7 +287,7 @@ export function matchActions(
key: `a:${a.id}`,
id: a.id,
title: translated,
category: a.category ? tx(a.category) : a.category,
category: a.category && tx(a.category),
icon: a.icon,
enabled,
indices,
@@ -335,13 +335,18 @@ export function mergeActionResults(actions: readonly ActionItem[], settings: rea
return byScore<ActionItem | SettingItem>([...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: [],
+3 -1
Переглянути файл
@@ -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;
+23 -1
Переглянути файл
@@ -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]!;
+3 -1
Переглянути файл
@@ -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<HTMLButtonElement, BarButtonProps>(function
{ variant = "status", mono, tooltip, shortcut, emphasis, className, type = "button", children, ...rest },
ref,
) {
const given = useGivenTx();
return (
<button
ref={ref}
type={type}
className={clsx(styles.button, styles[variant], mono && styles.mono, emphasis && styles.emphasis, className)}
{...tooltipAttrs(tooltip, shortcut)}
{...tooltipAttrs(given(tooltip), shortcut, true)}
{...rest}
>
{children}
+4 -4
Переглянути файл
@@ -5,7 +5,7 @@ import { shortcutLabel } from "@/platform/keymap";
import { Icon, type IconName } from "./icons";
import { tooltipAttrs } from "./Tooltip";
import styles from "./Button.module.css";
import { useTx } from "@/platform/i18n";
import { useGivenTx } from "@/platform/i18n";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost";
@@ -18,7 +18,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{ variant = "secondary", size = "normal", icon, className, children, type = "button", title, ...rest },
ref,
) {
const tx = useTx();
const tx = useGivenTx();
return (
<button
ref={ref}
@@ -69,7 +69,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
},
ref,
) {
const tx = useTx();
const tx = useGivenTx();
const a = action ? getAction(action) : undefined;
const label = tx(title) ?? a?.title ?? action ?? icon;
const keys = shortcut ?? (action ? shortcutLabel(action) : undefined);
@@ -86,7 +86,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
onClick?.(e);
if (!e.defaultPrevented && action) void executeAction(action, actionArg);
}}
{...tooltipAttrs(label, keys)}
{...tooltipAttrs(label, keys, true)}
{...rest}
>
<Icon name={icon} size={iconSize ?? (size === "small" ? 14 : 16)} />
+3 -3
Переглянути файл
@@ -2,7 +2,7 @@ import { useId, type ReactNode } from "react";
import clsx from "clsx";
import { Icon } from "./icons";
import styles from "./Checkbox.module.css";
import { useTx } from "@/platform/i18n";
import { useGivenTx } from "@/platform/i18n";
export interface CheckboxProps {
checked: boolean;
@@ -17,7 +17,7 @@ export interface CheckboxProps {
export function Checkbox({ checked, onChange, label, disabled, indeterminate, className, ...aria }: CheckboxProps) {
const id = useId();
const tx = useTx();
const tx = useGivenTx();
return (
<label className={clsx(styles.row, disabled && styles.disabled, className)} htmlFor={id}>
<span className={styles.boxWrap}>
@@ -56,7 +56,7 @@ export interface RadioProps {
/** Radio button, drawn like the checkbox with a round box and a dot. */
export function Radio({ checked, onChange, name, label, disabled, className }: RadioProps) {
const id = useId();
const tx = useTx();
const tx = useGivenTx();
return (
<label className={clsx(styles.row, disabled && styles.disabled, className)} htmlFor={id}>
<span className={styles.boxWrap}>
+7 -7
Переглянути файл
@@ -5,7 +5,7 @@ import { usePlatform } from "@/platform/platformInfo";
import { Button, IconButton } from "./Button";
import { Icon } from "./icons";
import { FOCUSABLE, onWindowDeactivate, openLayer, trapTab } from "./popupState";
import { useTx } from "@/platform/i18n";
import { tx, useGivenTx } from "@/platform/i18n";
import { selectableText } from "./selectableText";
import appIcon from "../../src-tauri/icons/128x128.png";
import styles from "./Dialog.module.css";
@@ -74,7 +74,7 @@ export function DialogButtons({
platform?: DialogPlatform;
}) {
const platform = useDialogPlatform(override);
const tx = useTx();
const given = useGivenTx();
return (
<div className={styles.buttons}>
{orderDialogButtons(buttons, platform).map((b) => (
@@ -82,12 +82,12 @@ export function DialogButtons({
key={b.label}
variant={b.role === "default" ? "primary" : "secondary"}
disabled={b.disabled}
title={tx(b.title)}
title={given(b.title)}
data-autofocus={b.autoFocus ? "" : undefined}
data-dialog-default={b.role === "default" ? "" : undefined}
onClick={b.onClick ?? (b.role === "cancel" ? onCancel : undefined)}
>
{tx(b.label)}
{given(b.label)}
</Button>
))}
</div>
@@ -225,8 +225,8 @@ function DialogInner({
}: DialogProps) {
const ref = useRef<HTMLDivElement>(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({
<div className={styles.messageText}>
{title != null && <div className={clsx(styles.messageTitle, selectableText)}>{title}</div>}
<div className={clsx(styles.messageBody, selectableText)}>
{typeof children === "string" ? tx(children) : children}
{typeof children === "string" ? given(children) : children}
</div>
{footerStart && <div className={styles.messageExtra}>{footerStart}</div>}
</div>
+8 -6
Переглянути файл
@@ -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 (
<div className={clsx(styles.empty, className)}>
{title && <div className={styles.emptyTitle}>{typeof title === "string" ? <T>{title}</T> : title}</div>}
{title && <div className={styles.emptyTitle}>{typeof title === "string" ? <Given>{title}</Given> : title}</div>}
{children && <div className={styles.emptyBody}>{children}</div>}
{actions && <div className={styles.emptyActions}>{actions}</div>}
</div>
@@ -125,6 +126,7 @@ export function Toast({
onPointerEnter,
onPointerLeave,
}: ToastProps) {
const tx = useTx();
return (
<div
className={clsx(styles.toast, className)}
@@ -137,16 +139,16 @@ export function Toast({
<div className={styles.toastMain}>
<div className={styles.toastHead}>
<span className={clsx(styles.toastTitle, selectableText)}>
{typeof title === "string" ? <T>{title}</T> : title}
{typeof title === "string" ? <Given>{title}</Given> : title}
</span>
{meta && <span className={styles.toastMeta}>{meta}</span>}
</div>
{body && <div className={styles.toastBody}>{typeof body === "string" ? <T>{body}</T> : body}</div>}
{body && <div className={styles.toastBody}>{typeof body === "string" ? <Given>{body}</Given> : body}</div>}
{actions && actions.length > 0 && (
<div className={styles.toastActions}>
{actions.map((a) => (
<button key={a.label} type="button" className={styles.link} onClick={a.run}>
<T>{a.label}</T>
<Given>{a.label}</Given>
</button>
))}
</div>
@@ -155,7 +157,7 @@ export function Toast({
{onClose && (
<IconButton
icon="close"
title="Dismiss"
title={tx("Dismiss")}
size="small"
iconSize={12}
className={styles.toastClose}
+3 -3
Переглянути файл
@@ -14,7 +14,7 @@ import { Icon, type IconName } from "./icons";
import { IconButton } from "./Button";
import styles from "./Input.module.css";
import { setContext } from "@/platform/context";
import { useTx } from "@/platform/i18n";
import { useGivenTx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
@@ -38,7 +38,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
},
ref,
) {
const tx = useTx();
const tx = useGivenTx();
return (
<input
ref={ref}
@@ -125,7 +125,7 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
},
ref,
) {
const tx = useTx();
const tx = useGivenTx();
const inner = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const setRef = (el: HTMLInputElement | HTMLTextAreaElement | null) => {
inner.current = el;
+13 -12
Переглянути файл
@@ -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<V extends string = string>({
onPreview,
...aria
}: SelectProps<V>) {
const tx = useTx();
const given = useGivenTx();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const current = options.find((o) => !o.section && o.value === value);
@@ -79,7 +79,8 @@ export function Select<V extends string = string>({
const q = query.trim().toLowerCase();
const tokens = q.split(/\s+/).filter(Boolean);
const matches = (o: SelectOption<V>) => {
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<V extends string = string>({
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<V extends string = string>({
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<V extends string = string>({
<>
{current?.icon && <Icon name={current.icon} size={14} />}
<span className={clsx(styles.value, !current && styles.placeholder)}>
{tx(current?.label ?? placeholder)}
{given(current?.label ?? placeholder)}
</span>
</>
)}
@@ -238,8 +239,8 @@ export function Select<V extends string = string>({
<div key={`${o.section ? "section" : "option"}:${o.value}`} className={styles.sizerRow}>
<span className={styles.check} />
{o.icon && <Icon name={o.icon} size={14} />}
<span>{tx(o.label)}</span>
{o.detail && <span className={styles.sizerDetail}>{tx(o.detail)}</span>}
<span>{given(o.label)}</span>
{o.detail && <span className={styles.sizerDetail}>{given(o.detail)}</span>}
</div>
))}
</div>
@@ -247,7 +248,7 @@ export function Select<V extends string = string>({
items={filtered}
getKey={(o) => `${o.section ? "section" : "option"}:${o.value}`}
isSection={isSection}
renderSection={(o) => <span className={styles.disabled}>{tx(o.label)}</span>}
renderSection={(o) => <span className={styles.disabled}>{given(o.label)}</span>}
activeIndex={activeIndex}
onActiveChange={(i) => {
if (!unavailable(filtered[i]!)) setActive(i);
@@ -256,7 +257,7 @@ export function Select<V extends string = string>({
focusable={false}
hoverSelects
rowHeight={24}
aria-label={tx(aria["aria-label"])}
aria-label={given(aria["aria-label"])}
className={styles.list}
renderItem={(o) => (
<span
@@ -265,10 +266,10 @@ export function Select<V extends string = string>({
>
<span className={styles.check}>{o.value === value && <Icon name="check" size={12} />}</span>
{o.icon && <Icon name={o.icon} size={14} />}
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{tx(o.label)}</span>
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{given(o.label)}</span>
{o.detail && (
<span className={styles.detail} title={o.detail}>
{tx(o.detail)}
{given(o.detail)}
</span>
)}
</span>
+6 -1
Переглянути файл
@@ -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);
}
+7 -6
Переглянути файл
@@ -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<HTMLDivElement>(null);
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
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({
<div
ref={scrollRef}
role="tablist"
aria-label={tx(aria["aria-label"])}
aria-label={given(aria["aria-label"])}
aria-orientation={vertical ? "vertical" : undefined}
className={styles.scroller}
onWheel={(e) => {
@@ -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 && <Icon name={item.icon} size={14} className={styles.tabIcon} />}
<span className={clsx(styles.tabLabel, item.italic && styles.italic)}>
{typeof item.label === "string" ? <T>{item.label}</T> : item.label}
{typeof item.label === "string" ? <Given>{item.label}</Given> : item.label}
</span>
{item.trailing}
{item.modified && !closable && <span className={styles.modified} aria-label={tx("Modified")} />}
+4 -2
Переглянути файл
@@ -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<string, string> {
/** `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<string, string> {
if (!text) return {};
const attrs: Record<string, string> = { "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;
+3 -2
Переглянути файл
@@ -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<string> | undefined, initial: Ite
}
export function Tree<T>(props: TreeProps<T>) {
const given = useGivenTx();
const {
roots,
getKey,
@@ -627,7 +628,7 @@ export function Tree<T>(props: TreeProps<T>) {
<div
ref={scrollRef}
role="tree"
aria-label={tx(props["aria-label"])}
aria-label={given(props["aria-label"])}
aria-multiselectable={multiSelect || undefined}
aria-activedescendant={
leadRow && !leadRow.loading && virtualizer.getVirtualItems().some((v) => v.index === leadIndex)
+1 -1
Переглянути файл
@@ -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";
+50 -31
Переглянути файл
@@ -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<number | null>(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 (
<Toast
className={styles.balloon}
level={n.level}
title={txt(n.title)}
body={n.body}
actions={notificationActions(n).map((a) => ({
label: txt(a.label),
run: () => {
dismissBalloon(n.id);
a.run();
},
}))}
onClose={() => dismissBalloon(n.id)}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
/>
<VerbatimTextContext.Provider value={!!n.verbatim}>
<Toast
className={styles.balloon}
level={n.level}
title={shown.title}
body={n.body}
actions={shown.actions.map((a) => ({
label: a.label,
run: () => {
dismissBalloon(n.id);
a.run();
},
}))}
onClose={() => dismissBalloon(n.id)}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
/>
</VerbatimTextContext.Provider>
);
}
@@ -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() {
<T>No notifications</T>
</EmptyState>
) : (
items.map((n) => (
<Toast
key={n.id}
className={styles.entry}
level={n.level}
title={txt(n.title)}
body={n.body}
meta={timeLabel(n.time)}
actions={notificationActions(n).map((a) => ({ ...a, label: txt(a.label) }))}
onClose={() => removeNotification(n.id)}
/>
))
items.map((n) => {
const shown = shownText(n);
return (
<VerbatimTextContext.Provider key={n.id} value={!!n.verbatim}>
<Toast
className={styles.entry}
level={n.level}
title={shown.title}
body={n.body}
meta={timeLabel(n.time)}
actions={shown.actions}
onClose={() => removeNotification(n.id)}
/>
</VerbatimTextContext.Provider>
);
})
)}
</div>
);
+6
Переглянути файл
@@ -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). */
+15 -7
Переглянути файл
@@ -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" ? (
<PromptDialog key={r.id} options={r.options} done={r.resolve} />
<VerbatimTextContext.Provider key={r.id} value={!!r.options.verbatim}>
<PromptDialog options={r.options} done={r.resolve} />
</VerbatimTextContext.Provider>
) : r.kind === "choose" ? (
<ChooseDialog key={r.id} options={r.options} done={r.resolve} />
) : (
<PickDialog key={r.id} options={r.options} done={r.resolve} />
<VerbatimTextContext.Provider key={r.id} value={!!r.options.verbatim}>
<PickDialog options={r.options} done={r.resolve} />
</VerbatimTextContext.Provider>
),
)}
</>
@@ -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 && (
<label className={styles.label}>
<T>{options.label}</T>
<Given>{options.label}</Given>
</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,
+19 -12
Переглянути файл
@@ -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 (
<Dialog
open
onClose={() => done("cancel")}
title={message.title}
icon={message.icon ?? "question"}
width={message.width ?? 460}
footerStart={message.checkbox && <Checkbox checked={checked} onChange={setChecked} label={message.checkbox} />}
buttons={withCancel ? [...declared, { label: t("common.cancel"), role: "cancel" }] : declared}
>
{message.body}
</Dialog>
<VerbatimTextContext.Provider value={!!message.verbatim}>
<Dialog
open
onClose={() => done("cancel")}
title={message.title}
icon={message.icon ?? "question"}
width={message.width ?? 460}
footerStart={message.checkbox && <Checkbox checked={checked} onChange={setChecked} label={message.checkbox} />}
buttons={withCancel ? [...declared, { label: t("common.cancel"), role: "cancel" }] : declared}
>
{message.body}
</Dialog>
</VerbatimTextContext.Provider>
);
}
+3
Переглянути файл
@@ -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<NotificationService["notify"]>[0]): number
title: n.title,
body: n.body,
actions: n.actions,
verbatim: n.verbatim,
time: Date.now(),
read: false,
balloon,
+15 -13
Переглянути файл
@@ -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)}
>
<span className={styles.stripeIcon}>
<Icon name={tw.icon} size={20} />
@@ -423,7 +428,7 @@ function StripeButton({
</span>
{showName && (
<span className={clsx(styles.stripeLabel, !/\s/.test(tw.title) && styles.stripeLabelWord)} aria-hidden="true">
<T>{tw.title}</T>
{titleOf(tw)}
</span>
)}
</button>
@@ -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)}
>
<span className={styles.title}>
<T>{titleMenu?.label ?? tw.title}</T>
</span>
<span className={styles.title}>{titleMenu ? tx(titleMenu.label) : titleOf(tw)}</span>
<Icon name="chevron-down" size={12} className={styles.titleChevron} />
</button>
<Menu
@@ -682,13 +686,11 @@ function HeaderRow({
onClose={() => 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`}
/>
</>
) : (
<span className={clsx(styles.title, styles.titlePlain)}>
<T>{tw.title}</T>
</span>
<span className={clsx(styles.title, styles.titlePlain)}>{titleOf(tw)}</span>
)}
{children && <div className={clsx(styles.headerContent, !showTitle && styles.headerContentOnly)}>{children}</div>}
<div className={styles.headerSpacer} />
@@ -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}
>
+1
Переглянути файл
@@ -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"],