fix: show plugin names and descriptions in the UI language (#1658)

* fix: show plugin names and descriptions in the UI language

Read plugin.name and plugin.description from each plugin's own locale
namespace, falling back to the manifest, everywhere core shows a plugin's
name: the Plugins manager, onboarding picker, host editor tabs and
sections, host filters and bulk actions, beta updates, interface presets
and plugin crash/loading notices.

* docs(sdk): document plugin.name and plugin.description locale keys

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
This commit is contained in:
ZacharyZcRandZacharyZcR authored and GitHub committed 2026-10-16 23:09:50 -05:00
1 parent fc77b6c756
commit 764d034098
20 files changed
+236 -45

No files matched your search

+15 -1
View File
@@ -105,7 +105,7 @@ Before you call a change finished:
- [ ] `npm run build` passes - [ ] `npm run build` passes
- [ ] `npm run format:check` passes - [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables) - [ ] `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 routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest - [ ] 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 - [ ] `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/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are. `locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English. 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
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use `docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
+24
View File
@@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
/** /**
* Where a plugin's i18n keys resolve from. * Where a plugin's i18n keys resolve from.
* *
@@ -19,3 +21,25 @@ export function pluginKey(
if (!key) return key; if (!key) return key;
return key.includes(":") ? key : `${pluginId}:${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 ?? "",
});
}
+10 -7
View File
@@ -20,6 +20,7 @@ import {
recommendedChoices, recommendedChoices,
} from "../plugin-choices"; } from "../plugin-choices";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
"Terminal", "Terminal",
@@ -79,8 +80,8 @@ export function PluginsStep({
}, [list, choices]); }, [list, choices]);
const names = useMemo( const names = useMemo(
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []), () => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []),
[list], [list, t],
); );
const changed = useMemo(() => { const changed = useMemo(() => {
@@ -260,7 +261,7 @@ export function PluginsStep({
/> />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium"> <span className="flex items-center gap-1.5 text-xs font-medium">
{plugin.name} {pluginName(t, plugin)}
{plugin.recommended && ( {plugin.recommended && (
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand"> <span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
{t("onboarding.pluginsRecommended")} {t("onboarding.pluginsRecommended")}
@@ -268,7 +269,7 @@ export function PluginsStep({
)} )}
</span> </span>
<span className="text-[10px] leading-snug text-muted-foreground"> <span className="text-[10px] leading-snug text-muted-foreground">
{plugin.description} {pluginDescription(t, plugin)}
</span> </span>
{forcedBy.has(plugin.id) && ( {forcedBy.has(plugin.id) && (
<span className="text-[10px] text-accent-brand"> <span className="text-[10px] text-accent-brand">
@@ -281,7 +282,7 @@ export function PluginsStep({
</div> </div>
<div <div
role="radiogroup" role="radiogroup"
aria-label={plugin.name} aria-label={pluginName(t, plugin)}
className="flex shrink-0 border border-border" className="flex shrink-0 border border-border"
> >
{CHOICES.map((option) => ( {CHOICES.map((option) => (
@@ -335,9 +336,11 @@ export function PluginsStep({
} }
/> />
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">{plugin.name}</span> <span className="text-xs font-medium">
{pluginName(t, plugin)}
</span>
<span className="text-[10px] leading-snug text-muted-foreground"> <span className="text-[10px] leading-snug text-muted-foreground">
{plugin.description} {pluginDescription(t, plugin)}
</span> </span>
<span className="text-[10px] text-muted-foreground/80"> <span className="text-[10px] text-muted-foreground/80">
{t("onboarding.pluginsConsentNote")} {t("onboarding.pluginsConsentNote")}
+2 -1
View File
@@ -2,6 +2,7 @@ import { CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { usePermissions } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions";
import { pluginName } from "@/lib/plugin-i18n";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { usePluginStore } from "./plugin-store"; import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership"; import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
@@ -27,7 +28,7 @@ export function PluginViewPlaceholder({
usePluginStore(); usePluginStore();
const { has } = usePermissions(); const { has } = usePermissions();
const { status, owner } = unregisteredViewStatus(kind, viewId); const { status, owner } = unregisteredViewStatus(kind, viewId);
const name = owner?.summary.name ?? viewId; const name = owner ? pluginName(t, owner.summary) : viewId;
if (status === "loading") { if (status === "loading") {
return ( return (
+3 -1
View File
@@ -10,6 +10,7 @@ import {
} from "react"; } from "react";
import { scopeHostFields } from "./host-scope"; import { scopeHostFields } from "./host-scope";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { pluginName } from "@/lib/plugin-i18n";
import { getPluginRecord } from "./plugin-store"; import { getPluginRecord } from "./plugin-store";
const PluginScopeContext = createContext<string | null>(null); const PluginScopeContext = createContext<string | null>(null);
@@ -27,7 +28,8 @@ function CrashNotice({
onRetry: () => void; onRetry: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId; const summary = getPluginRecord(pluginId)?.summary;
const name = summary ? pluginName(t, summary) : pluginId;
return ( return (
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<span>{t("plugins.runtime.crashed", { name })}</span> <span>{t("plugins.runtime.crashed", { name })}</span>
+10 -2
View File
@@ -25,6 +25,7 @@ import {
type RegistryListing, type RegistryListing,
} from "@/api/plugins-api"; } from "@/api/plugins-api";
import { useConfirm } from "@/components/surface/surface-scope"; import { useConfirm } from "@/components/surface/surface-scope";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model"; import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
import type { ConsentRequest } from "./PluginConsentPrompt"; import type { ConsentRequest } from "./PluginConsentPrompt";
@@ -109,9 +110,16 @@ export function usePluginsManager() {
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange); 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( 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( const run = useCallback(
+3 -7
View File
@@ -11,7 +11,7 @@ import { toast } from "sonner";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card"; import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared"; 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 { PluginIcon } from "@/lib/plugin-icon";
import { import {
getPluginAdminSettings, getPluginAdminSettings,
@@ -68,9 +68,7 @@ export function FeatureSettingsSection({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<AccordionSection <AccordionSection
label={t(pluginKey(plugin.id, "plugin.name"), { label={pluginName(t, plugin)}
defaultValue: plugin.name,
})}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />} icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open} open={open}
onToggle={onToggle} onToggle={onToggle}
@@ -198,9 +196,7 @@ export function FeatureSettingsForm({
{!running && ( {!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> <div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", { {t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), { name: pluginName(t, plugin),
defaultValue: plugin.name,
}),
})} })}
</div> </div>
)} )}
+5 -2
View File
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card"; import { SectionCard } from "@/components/section-card";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { type PluginSummary } from "@/api/plugins-api"; import { type PluginSummary } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins"; import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { usePluginScope } from "@/plugin-host/scope"; import { usePluginScope } from "@/plugin-host/scope";
@@ -141,13 +142,15 @@ function HostPluginSection({
return ( return (
<SectionCard <SectionCard
title={plugin.name} title={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />} icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
action={<DocsLink href={plugin.docs} />} action={<DocsLink href={plugin.docs} />}
> >
{!running && ( {!running && (
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> <div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", { name: plugin.name })} {t("settings.featureUnavailable", {
name: pluginName(t, plugin),
})}
</div> </div>
)} )}
+2 -4
View File
@@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { reportCoreIssueUrl } from "@/lib/issue-url"; import { reportCoreIssueUrl } from "@/lib/issue-url";
import { cn } from "@/lib/utils"; 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 { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types"; import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios"; import type { UserPreferences } from "@/main-axios";
@@ -121,9 +121,7 @@ export function SettingsTab({
id: `plugin:${plugin.id}`, id: `plugin:${plugin.id}`,
band: "plugins", band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"), labelKey: pluginKey(plugin.id, "plugin.name"),
label: t(pluginKey(plugin.id, "plugin.name"), { label: pluginName(t, plugin),
defaultValue: plugin.name,
}),
icon: SettingsIcon, icon: SettingsIcon,
pluginId: plugin.id, pluginId: plugin.id,
searchKeys: fields.flatMap((field) => searchKeys: fields.flatMap((field) =>
+2 -1
View File
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model"; import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs"; import { docsUrl } from "@/lib/docs";
import { pluginName } from "@/lib/plugin-i18n";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard"; import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta"; const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
@@ -262,7 +263,7 @@ function InstanceUpdates() {
{betaPlugins.map((plugin) => ( {betaPlugins.map((plugin) => (
<SettingRow <SettingRow
key={plugin.id} key={plugin.id}
label={plugin.name} label={pluginName(t, plugin)}
description={ description={
plugin.channel === "beta" plugin.channel === "beta"
? `v${plugin.version}` ? `v${plugin.version}`
+2 -1
View File
@@ -7,6 +7,7 @@
*/ */
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { pluginKey } from "@/lib/plugin-i18n";
import { import {
hostEditorSectionList, hostEditorSectionList,
registerHostEditorSection, registerHostEditorSection,
@@ -95,7 +96,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
pluginId: plugin.id, pluginId: plugin.id,
group: placement?.group ?? host.editorGroup ?? "top", group: placement?.group ?? host.editorGroup ?? "top",
order: placement?.order ?? host.editorOrder ?? 100, order: placement?.order ?? host.editorOrder ?? 100,
labelKey: plugin.name, labelKey: pluginKey(plugin.id, "plugin.name"),
label: plugin.name, label: plugin.name,
// Nothing but the plugin's host settings, so it works for defaults too. // Nothing but the plugin's host settings, so it works for defaults too.
defaults: ownSections.has(plugin.id) ? "only" : true, defaults: ownSections.has(plugin.id) ? "only" : true,
+1 -1
View File
@@ -730,7 +730,7 @@ export function HostEditor({
<EditorSection <EditorSection
key={id} key={id}
id={id} id={id}
label={section.label ?? t(section.labelKey)} label={t(section.labelKey, { defaultValue: section.label })}
> >
<LazySection> <LazySection>
<Section <Section
+6 -6
View File
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
pluginId?: string; pluginId?: string;
group: "top" | "ssh"; group: "top" | "ssh";
labelKey: string; labelKey: string;
/** Already-translated label, used instead of labelKey when set. */ /** Shown when labelKey has no translation. */
label?: string; label?: string;
icon?: ComponentType<{ className?: string }>; icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */ /** Position among the group's tabs, core ones included. */
@@ -88,7 +88,7 @@ function sectionVisible(
function mergeByOrder( function mergeByOrder(
core: (HostTab & { order: number })[], core: (HostTab & { order: number })[],
group: "top" | "ssh", group: "top" | "ssh",
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
protocols: Record<string, boolean> | undefined, protocols: Record<string, boolean> | undefined,
defaultsMode = false, defaultsMode = false,
): HostTab[] { ): HostTab[] {
@@ -104,7 +104,7 @@ function mergeByOrder(
const Icon = section.icon; const Icon = section.icon;
return { return {
id: section.id, id: section.id,
label: section.label ?? t(section.labelKey), label: t(section.labelKey, { defaultValue: section.label }),
icon: Icon ? <Icon className="size-3" /> : null, icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000, order: section.order ?? 1000,
}; };
@@ -119,7 +119,7 @@ function mergeByOrder(
* `visible`; without, every registered top tab is listed. * `visible`; without, every registered top tab is listed.
*/ */
export function makeHostTabs( export function makeHostTabs(
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
protocols?: Record<string, boolean>, protocols?: Record<string, boolean>,
defaultsMode = false, defaultsMode = false,
): HostTab[] { ): HostTab[] {
@@ -146,7 +146,7 @@ export function makeHostTabs(
} }
export function makeHostSshSubTabs( export function makeHostSshSubTabs(
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
protocols?: Record<string, boolean>, protocols?: Record<string, boolean>,
defaultsMode = false, defaultsMode = false,
): HostTab[] { ): HostTab[] {
@@ -167,7 +167,7 @@ export function makeHostSshSubTabs(
} }
export function makeCredentialTabs( export function makeCredentialTabs(
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
): CredentialTab[] { ): CredentialTab[] {
return [ return [
{ {
+2 -1
View File
@@ -55,6 +55,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
import { sortHostTree, type SortKey } from "@/sidebar/host-sort"; import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
import { withLiveStatus } from "@/sidebar/live-host-status"; import { withLiveStatus } from "@/sidebar/live-host-status";
import { useServerStatus } from "@/lib/ServerStatusContext"; import { useServerStatus } from "@/lib/ServerStatusContext";
import { pluginName } from "@/lib/plugin-i18n";
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences"; import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock"; import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import type { import type {
@@ -816,7 +817,7 @@ export function HostsPanel({
} }
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
{plugin.name} {pluginName(t, plugin)}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
{allTags.length > 0 && ( {allTags.length > 0 && (
+3 -1
View File
@@ -12,6 +12,7 @@ import {
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset"; import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginStore } from "@/plugin-host/plugin-store"; import { usePluginStore } from "@/plugin-host/plugin-store";
import { import {
UI_AREA_KEYS, UI_AREA_KEYS,
@@ -67,9 +68,10 @@ export function InterfacePresetSettings({
) )
.map((area) => { .map((area) => {
const pluginId = area.slice("plugin:".length); const pluginId = area.slice("plugin:".length);
const summary = pluginStore.records.get(pluginId)?.summary;
return { return {
key: area, key: area,
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId, label: summary ? pluginName(t, summary) : pluginId,
}; };
}); });
return [...core, ...plugins]; return [...core, ...plugins];
+3 -2
View File
@@ -11,6 +11,7 @@ import {
import { updatePluginHostSettings } from "@/api/plugins-api"; import { updatePluginHostSettings } from "@/api/plugins-api";
import { resetHostToDefaults } from "@/api/host-defaults-api"; import { resetHostToDefaults } from "@/api/host-defaults-api";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginHostSections } from "@/settings/HostPluginSections"; import { usePluginHostSections } from "@/settings/HostPluginSections";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
@@ -1284,7 +1285,7 @@ export function SidebarTree({
value value
? "hosts.enablePluginFeature" ? "hosts.enablePluginFeature"
: "hosts.disablePluginFeature", : "hosts.disablePluginFeature",
{ name: plugin.name }, { name: pluginName(t, plugin) },
)} )}
</DropdownMenuItem> </DropdownMenuItem>
)), )),
@@ -1307,7 +1308,7 @@ export function SidebarTree({
{ id: "core", label: t("hostDefaults.resetCore") }, { id: "core", label: t("hostDefaults.resetCore") },
...hostSettingPlugins.map((plugin) => ({ ...hostSettingPlugins.map((plugin) => ({
id: plugin.id, id: plugin.id,
label: plugin.name, label: pluginName(t, plugin),
})), })),
].map((option) => ( ].map((option) => (
<DropdownMenuItem <DropdownMenuItem
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import i18next from "i18next";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
async function zhCN() {
const i18n = i18next.createInstance();
await i18n.init({
lng: "zh-CN",
fallbackLng: "en",
fallbackNS: "translation",
resources: {
"zh-CN": {
translation: { common: { save: "保存" } },
"host-metrics": {
plugin: { name: "主机指标", description: "查看主机负载" },
},
"no-description": { plugin: { name: "无描述" } },
},
},
});
return i18n.t;
}
describe("pluginName", () => {
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("");
});
});
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react"; 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"; import type { OnboardingPluginList } from "@/api/plugins-api";
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
@@ -186,4 +188,38 @@ describe("PluginsStep", () => {
expect(ok).toBe(true); expect(ok).toBe(true);
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1); 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(
<I18nextProvider i18n={i18n}>
<PluginsStep
mode="full"
isAdmin
isDesktop={false}
shellReady={false}
setCanContinue={() => {}}
setBeforeNext={() => {}}
/>
</I18nextProvider>,
);
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();
});
}); });
@@ -35,9 +35,17 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({
vi.mock("@/components/surface/surface-scope", () => ({ vi.mock("@/components/surface/surface-scope", () => ({
useConfirm: () => vi.fn(async () => true), useConfirm: () => vi.fn(async () => true),
})); }));
vi.mock("react-i18next", () => ({ const i18n = vi.hoisted(() => {
useTranslation: () => ({ t: (key: string) => key }), const strings: Record<string, string> = {
})); "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() } })); vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { usePluginsManager } from "@/plugins/use-plugins-manager"; 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"] }, { 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" },
]);
});
@@ -33,7 +33,10 @@ vi.mock("@/api/plugins-api", () => ({
})); }));
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }), useTranslation: () => ({
t: (key: string, options?: { defaultValue?: string }) =>
options?.defaultValue ?? key,
}),
})); }));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary { function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
@@ -75,9 +78,10 @@ describe("syncHostFeatureTabs", () => {
"feature:docker", "feature:docker",
"feature:warpgate", "feature:warpgate",
]); ]);
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe( expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({
"Docker", labelKey: "docker:plugin.name",
); label: "Docker",
});
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined(); expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
}); });