diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 4bcd861d6..9c219fed9 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -105,7 +105,7 @@ Before you call a change finished: - [ ] `npm run build` passes - [ ] `npm run format:check` passes - [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables) -- [ ] New strings are in `locales/en.json` +- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest - [ ] New routes have an `@openapi` block and a test, including without the permission - [ ] New env vars are listed in `env` in the manifest - [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does @@ -538,6 +538,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go `locales/translated/.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are. Untranslated strings show in English. +Termix shows your plugin's name and description in the plugin manager, the setup picker and settings. +Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and +`description` in the manifest, so they get translated too. Without them, the English manifest text shows in +every language. + +```json +{ + "plugin": { + "name": "Host Metrics", + "description": "Live CPU, memory, disk, network and GPU stats." + } +} +``` + ## Docs `docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use diff --git a/src/ui/lib/plugin-i18n.ts b/src/ui/lib/plugin-i18n.ts index 9b9dda30e..1e85c8b65 100644 --- a/src/ui/lib/plugin-i18n.ts +++ b/src/ui/lib/plugin-i18n.ts @@ -1,3 +1,5 @@ +import type { TFunction } from "i18next"; + /** * Where a plugin's i18n keys resolve from. * @@ -19,3 +21,25 @@ export function pluginKey( if (!key) return key; return key.includes(":") ? key : `${pluginId}:${key}`; } + +/** + * A plugin's name in the UI language: "plugin.name" from its own locales, + * else the manifest's. A plugin whose strings are not loaded, like one only + * listed in a registry, shows the manifest name. + */ +export function pluginName( + t: TFunction, + plugin: { id: string; name: string }, +): string { + return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name }); +} + +/** Same as pluginName, for "plugin.description". */ +export function pluginDescription( + t: TFunction, + plugin: { id: string; description?: string | null }, +): string { + return t(pluginKey(plugin.id, "plugin.description"), { + defaultValue: plugin.description ?? "", + }); +} diff --git a/src/ui/onboarding/steps/PluginsStep.tsx b/src/ui/onboarding/steps/PluginsStep.tsx index 22435e56b..a0bd4071d 100644 --- a/src/ui/onboarding/steps/PluginsStep.tsx +++ b/src/ui/onboarding/steps/PluginsStep.tsx @@ -20,6 +20,7 @@ import { recommendedChoices, } from "../plugin-choices"; import { DocsLink } from "@/components/docs-link"; +import { pluginDescription, pluginName } from "@/lib/plugin-i18n"; const CATEGORY_ORDER = [ "Terminal", @@ -79,8 +80,8 @@ export function PluginsStep({ }, [list, choices]); const names = useMemo( - () => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []), - [list], + () => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []), + [list, t], ); const changed = useMemo(() => { @@ -260,7 +261,7 @@ export function PluginsStep({ />
- {plugin.name} + {pluginName(t, plugin)} {plugin.recommended && ( {t("onboarding.pluginsRecommended")} @@ -268,7 +269,7 @@ export function PluginsStep({ )} - {plugin.description} + {pluginDescription(t, plugin)} {forcedBy.has(plugin.id) && ( @@ -281,7 +282,7 @@ export function PluginsStep({
{CHOICES.map((option) => ( @@ -335,9 +336,11 @@ export function PluginsStep({ } />
- {plugin.name} + + {pluginName(t, plugin)} + - {plugin.description} + {pluginDescription(t, plugin)} {t("onboarding.pluginsConsentNote")} diff --git a/src/ui/plugin-host/PluginViewPlaceholder.tsx b/src/ui/plugin-host/PluginViewPlaceholder.tsx index b8d411b06..2fb14ead2 100644 --- a/src/ui/plugin-host/PluginViewPlaceholder.tsx +++ b/src/ui/plugin-host/PluginViewPlaceholder.tsx @@ -2,6 +2,7 @@ import { CircleSlash } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/button"; import { usePermissions } from "@/hooks/use-permissions"; +import { pluginName } from "@/lib/plugin-i18n"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { usePluginStore } from "./plugin-store"; import { unregisteredViewStatus, type ViewKind } from "./view-ownership"; @@ -27,7 +28,7 @@ export function PluginViewPlaceholder({ usePluginStore(); const { has } = usePermissions(); const { status, owner } = unregisteredViewStatus(kind, viewId); - const name = owner?.summary.name ?? viewId; + const name = owner ? pluginName(t, owner.summary) : viewId; if (status === "loading") { return ( diff --git a/src/ui/plugin-host/scope.tsx b/src/ui/plugin-host/scope.tsx index fc7283375..060065e89 100644 --- a/src/ui/plugin-host/scope.tsx +++ b/src/ui/plugin-host/scope.tsx @@ -10,6 +10,7 @@ import { } from "react"; import { scopeHostFields } from "./host-scope"; import { useTranslation } from "react-i18next"; +import { pluginName } from "@/lib/plugin-i18n"; import { getPluginRecord } from "./plugin-store"; const PluginScopeContext = createContext(null); @@ -27,7 +28,8 @@ function CrashNotice({ onRetry: () => void; }) { const { t } = useTranslation(); - const name = getPluginRecord(pluginId)?.summary.name ?? pluginId; + const summary = getPluginRecord(pluginId)?.summary; + const name = summary ? pluginName(t, summary) : pluginId; return (
{t("plugins.runtime.crashed", { name })} diff --git a/src/ui/plugins/use-plugins-manager.ts b/src/ui/plugins/use-plugins-manager.ts index e645fb008..7c0debd33 100644 --- a/src/ui/plugins/use-plugins-manager.ts +++ b/src/ui/plugins/use-plugins-manager.ts @@ -25,6 +25,7 @@ import { type RegistryListing, } from "@/api/plugins-api"; import { useConfirm } from "@/components/surface/surface-scope"; +import { pluginDescription, pluginName } from "@/lib/plugin-i18n"; import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model"; import type { ConsentRequest } from "./PluginConsentPrompt"; @@ -109,9 +110,16 @@ export function usePluginsManager() { window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange); }, []); + // Named in the UI language once here, so lists, sorting, search, dialogs + // and toasts all show the same name. const plugins = useMemo( - () => mergePlugins(installed, registry?.plugins ?? null), - [installed, registry], + () => + mergePlugins(installed, registry?.plugins ?? null).map((plugin) => ({ + ...plugin, + name: pluginName(t, plugin), + description: pluginDescription(t, plugin), + })), + [installed, registry, t], ); const run = useCallback( diff --git a/src/ui/settings/FeatureSettingsSections.tsx b/src/ui/settings/FeatureSettingsSections.tsx index b1047924a..8b05c2e07 100644 --- a/src/ui/settings/FeatureSettingsSections.tsx +++ b/src/ui/settings/FeatureSettingsSections.tsx @@ -11,7 +11,7 @@ import { toast } from "sonner"; import { Button } from "@/components/button"; import { SectionCard } from "@/components/section-card"; import { AccordionSection } from "@/sidebar/AdminSettingsShared"; -import { pluginKey } from "@/lib/plugin-i18n"; +import { pluginKey, pluginName } from "@/lib/plugin-i18n"; import { PluginIcon } from "@/lib/plugin-icon"; import { getPluginAdminSettings, @@ -68,9 +68,7 @@ export function FeatureSettingsSection({ const { t } = useTranslation(); return ( } open={open} onToggle={onToggle} @@ -198,9 +196,7 @@ export function FeatureSettingsForm({ {!running && (
{t("settings.featureUnavailable", { - name: t(pluginKey(plugin.id, "plugin.name"), { - defaultValue: plugin.name, - }), + name: pluginName(t, plugin), })}
)} diff --git a/src/ui/settings/HostPluginSections.tsx b/src/ui/settings/HostPluginSections.tsx index a3c895596..d4fb2057b 100644 --- a/src/ui/settings/HostPluginSections.tsx +++ b/src/ui/settings/HostPluginSections.tsx @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { SectionCard } from "@/components/section-card"; import { DocsLink } from "@/components/docs-link"; import { PluginIcon } from "@/lib/plugin-icon"; +import { pluginName } from "@/lib/plugin-i18n"; import { type PluginSummary } from "@/api/plugins-api"; import { useInstalledPlugins } from "@/hooks/use-installed-plugins"; import { usePluginScope } from "@/plugin-host/scope"; @@ -141,13 +142,15 @@ function HostPluginSection({ return ( } action={} > {!running && (
- {t("settings.featureUnavailable", { name: plugin.name })} + {t("settings.featureUnavailable", { + name: pluginName(t, plugin), + })}
)} diff --git a/src/ui/settings/SettingsTab.tsx b/src/ui/settings/SettingsTab.tsx index aa041bf30..2b878b571 100644 --- a/src/ui/settings/SettingsTab.tsx +++ b/src/ui/settings/SettingsTab.tsx @@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state"; import { isElectron } from "@/lib/electron"; import { reportCoreIssueUrl } from "@/lib/issue-url"; import { cn } from "@/lib/utils"; -import { pluginKey } from "@/lib/plugin-i18n"; +import { pluginKey, pluginName } from "@/lib/plugin-i18n"; import { PluginIcon } from "@/lib/plugin-icon"; import type { Host } from "@/types/ui-types"; import type { UserPreferences } from "@/main-axios"; @@ -121,9 +121,7 @@ export function SettingsTab({ id: `plugin:${plugin.id}`, band: "plugins", labelKey: pluginKey(plugin.id, "plugin.name"), - label: t(pluginKey(plugin.id, "plugin.name"), { - defaultValue: plugin.name, - }), + label: pluginName(t, plugin), icon: SettingsIcon, pluginId: plugin.id, searchKeys: fields.flatMap((field) => diff --git a/src/ui/settings/UpdatesSettings.tsx b/src/ui/settings/UpdatesSettings.tsx index f27b76990..dd4930e0a 100644 --- a/src/ui/settings/UpdatesSettings.tsx +++ b/src/ui/settings/UpdatesSettings.tsx @@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron"; import { isPrerelease } from "@/plugins/plugin-model"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { docsUrl } from "@/lib/docs"; +import { pluginName } from "@/lib/plugin-i18n"; import { DesktopUpdatesCard } from "./DesktopUpdatesCard"; const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta"; @@ -262,7 +263,7 @@ function InstanceUpdates() { {betaPlugins.map((plugin) => (
; /** Position among the group's tabs, core ones included. */ @@ -88,7 +88,7 @@ function sectionVisible( function mergeByOrder( core: (HostTab & { order: number })[], group: "top" | "ssh", - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, protocols: Record | undefined, defaultsMode = false, ): HostTab[] { @@ -104,7 +104,7 @@ function mergeByOrder( const Icon = section.icon; return { id: section.id, - label: section.label ?? t(section.labelKey), + label: t(section.labelKey, { defaultValue: section.label }), icon: Icon ? : null, order: section.order ?? 1000, }; @@ -119,7 +119,7 @@ function mergeByOrder( * `visible`; without, every registered top tab is listed. */ export function makeHostTabs( - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, protocols?: Record, defaultsMode = false, ): HostTab[] { @@ -146,7 +146,7 @@ export function makeHostTabs( } export function makeHostSshSubTabs( - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, protocols?: Record, defaultsMode = false, ): HostTab[] { @@ -167,7 +167,7 @@ export function makeHostSshSubTabs( } export function makeCredentialTabs( - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, ): CredentialTab[] { return [ { diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index d7ae21e88..af79a50c8 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -55,6 +55,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types"; import { sortHostTree, type SortKey } from "@/sidebar/host-sort"; import { withLiveStatus } from "@/sidebar/live-host-status"; import { useServerStatus } from "@/lib/ServerStatusContext"; +import { pluginName } from "@/lib/plugin-i18n"; import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences"; import { useArrangeLock } from "@/sidebar/use-arrange-lock"; import type { @@ -816,7 +817,7 @@ export function HostsPanel({ } onSelect={(e) => e.preventDefault()} > - {plugin.name} + {pluginName(t, plugin)} ))} {allTags.length > 0 && ( diff --git a/src/ui/sidebar/InterfacePresetSettings.tsx b/src/ui/sidebar/InterfacePresetSettings.tsx index b305c69a2..b46da8103 100644 --- a/src/ui/sidebar/InterfacePresetSettings.tsx +++ b/src/ui/sidebar/InterfacePresetSettings.tsx @@ -12,6 +12,7 @@ import { import { Button } from "@/components/button"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { applyPresetSideEffects } from "@/lib/apply-ui-preset"; +import { pluginName } from "@/lib/plugin-i18n"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { UI_AREA_KEYS, @@ -67,9 +68,10 @@ export function InterfacePresetSettings({ ) .map((area) => { const pluginId = area.slice("plugin:".length); + const summary = pluginStore.records.get(pluginId)?.summary; return { key: area, - label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId, + label: summary ? pluginName(t, summary) : pluginId, }; }); return [...core, ...plugins]; diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index ce5050d1a..4a6d1ffeb 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -11,6 +11,7 @@ import { import { updatePluginHostSettings } from "@/api/plugins-api"; import { resetHostToDefaults } from "@/api/host-defaults-api"; import { PluginIcon } from "@/lib/plugin-icon"; +import { pluginName } from "@/lib/plugin-i18n"; import { usePluginHostSections } from "@/settings/HostPluginSections"; import { useTranslation } from "react-i18next"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -1284,7 +1285,7 @@ export function SidebarTree({ value ? "hosts.enablePluginFeature" : "hosts.disablePluginFeature", - { name: plugin.name }, + { name: pluginName(t, plugin) }, )} )), @@ -1307,7 +1308,7 @@ export function SidebarTree({ { id: "core", label: t("hostDefaults.resetCore") }, ...hostSettingPlugins.map((plugin) => ({ id: plugin.id, - label: plugin.name, + label: pluginName(t, plugin), })), ].map((option) => ( { + it("reads the name from the plugin's own namespace", async () => { + const t = await zhCN(); + expect(pluginName(t, { id: "host-metrics", name: "Host Metrics" })).toBe( + "主机指标", + ); + }); + + it("falls back to the manifest name when the plugin has no strings", async () => { + const t = await zhCN(); + expect(pluginName(t, { id: "not-loaded", name: "Not Loaded" })).toBe( + "Not Loaded", + ); + }); +}); + +describe("pluginDescription", () => { + it("reads the description from the plugin's own namespace", async () => { + const t = await zhCN(); + expect( + pluginDescription(t, { + id: "host-metrics", + description: "See host load", + }), + ).toBe("查看主机负载"); + }); + + it("falls back to the manifest description, or nothing", async () => { + const t = await zhCN(); + expect( + pluginDescription(t, { id: "no-description", description: "Plain" }), + ).toBe("Plain"); + expect(pluginDescription(t, { id: "not-loaded" })).toBe(""); + }); +}); diff --git a/src/ui/tests/onboarding/steps/PluginsStep.test.tsx b/src/ui/tests/onboarding/steps/PluginsStep.test.tsx index 3958011ad..18a782e33 100644 --- a/src/ui/tests/onboarding/steps/PluginsStep.test.tsx +++ b/src/ui/tests/onboarding/steps/PluginsStep.test.tsx @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import i18next from "i18next"; +import { I18nextProvider, initReactI18next } from "react-i18next"; import type { OnboardingPluginList } from "@/api/plugins-api"; const api = vi.hoisted(() => ({ @@ -186,4 +188,38 @@ describe("PluginsStep", () => { expect(ok).toBe(true); expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1); }); + + it("shows plugin names and descriptions in the UI language", async () => { + const i18n = i18next.createInstance(); + await i18n.use(initReactI18next).init({ + lng: "zh-CN", + fallbackNS: "translation", + resources: { + "zh-CN": { + translation: {}, + term: { plugin: { name: "终端", description: "终端插件" } }, + stats: { plugin: { name: "统计" } }, + }, + }, + }); + render( + + {}} + setBeforeNext={() => {}} + /> + , + ); + expect(await screen.findByText("终端")).toBeTruthy(); + expect(screen.getByText("终端插件")).toBeTruthy(); + expect(screen.getByRole("radiogroup", { name: "终端" })).toBeTruthy(); + expect(screen.getByText("统计")).toBeTruthy(); + expect(screen.getByText("stats plugin")).toBeTruthy(); + expect(screen.getByText("BASE")).toBeTruthy(); + expect(screen.getByText("base plugin")).toBeTruthy(); + }); }); diff --git a/src/ui/tests/plugins/use-plugins-manager.test.tsx b/src/ui/tests/plugins/use-plugins-manager.test.tsx index 0be2b7a25..8cd538c22 100644 --- a/src/ui/tests/plugins/use-plugins-manager.test.tsx +++ b/src/ui/tests/plugins/use-plugins-manager.test.tsx @@ -35,9 +35,17 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({ vi.mock("@/components/surface/surface-scope", () => ({ useConfirm: () => vi.fn(async () => true), })); -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); +const i18n = vi.hoisted(() => { + const strings: Record = { + "sample-plugin:plugin.name": "示例", + "sample-plugin:plugin.description": "一个示例插件", + }; + return { + t: (key: string, options?: { defaultValue?: string }) => + strings[key] ?? options?.defaultValue ?? key, + }; +}); +vi.mock("react-i18next", () => ({ useTranslation: () => i18n })); vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); import { usePluginsManager } from "@/plugins/use-plugins-manager"; @@ -69,3 +77,33 @@ it("waits for local consent and sends the reviewed version and capabilities", as { version: "1.0.0", capabilities: ["kv:own"] }, ); }); + +it("names plugins in the UI language, falling back to the manifest", async () => { + api.getPluginRegistry.mockResolvedValueOnce({ + plugins: [ + { + id: "browse-only", + name: "Browse Only", + description: "Not installed", + author: "someone", + category: "Tools", + latestVersion: "1.0.0", + versions: [], + bundled: false, + }, + ], + managedByServer: true, + registry: {}, + } as never); + const { result } = renderHook(() => usePluginsManager()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect( + result.current.plugins.map(({ name, description }) => ({ + name, + description, + })), + ).toEqual([ + { name: "示例", description: "一个示例插件" }, + { name: "Browse Only", description: "Not installed" }, + ]); +}); diff --git a/src/ui/tests/settings/host-feature-tabs.test.tsx b/src/ui/tests/settings/host-feature-tabs.test.tsx index cb44b9b67..78c32d50e 100644 --- a/src/ui/tests/settings/host-feature-tabs.test.tsx +++ b/src/ui/tests/settings/host-feature-tabs.test.tsx @@ -33,7 +33,10 @@ vi.mock("@/api/plugins-api", () => ({ })); vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? key, + }), })); function plugin(overrides: Partial = {}): PluginSummary { @@ -75,9 +78,10 @@ describe("syncHostFeatureTabs", () => { "feature:docker", "feature:warpgate", ]); - expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe( - "Docker", - ); + expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({ + labelKey: "docker:plugin.name", + label: "Docker", + }); expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined(); });