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

+24
View File
@@ -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 ?? "",
});
}
+10 -7
View File
@@ -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({
/>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
{plugin.name}
{pluginName(t, plugin)}
{plugin.recommended && (
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
{t("onboarding.pluginsRecommended")}
@@ -268,7 +269,7 @@ export function PluginsStep({
)}
</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{plugin.description}
{pluginDescription(t, plugin)}
</span>
{forcedBy.has(plugin.id) && (
<span className="text-[10px] text-accent-brand">
@@ -281,7 +282,7 @@ export function PluginsStep({
</div>
<div
role="radiogroup"
aria-label={plugin.name}
aria-label={pluginName(t, plugin)}
className="flex shrink-0 border border-border"
>
{CHOICES.map((option) => (
@@ -335,9 +336,11 @@ export function PluginsStep({
}
/>
<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">
{plugin.description}
{pluginDescription(t, plugin)}
</span>
<span className="text-[10px] text-muted-foreground/80">
{t("onboarding.pluginsConsentNote")}
+2 -1
View File
@@ -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 (
+3 -1
View File
@@ -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<string | null>(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 (
<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>
+10 -2
View File
@@ -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(
+3 -7
View File
@@ -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 (
<AccordionSection
label={t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
})}
label={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
@@ -198,9 +196,7 @@ export function FeatureSettingsForm({
{!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
name: pluginName(t, plugin),
})}
</div>
)}
+5 -2
View File
@@ -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 (
<SectionCard
title={plugin.name}
title={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
action={<DocsLink href={plugin.docs} />}
>
{!running && (
<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>
)}
+2 -4
View File
@@ -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) =>
+2 -1
View File
@@ -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) => (
<SettingRow
key={plugin.id}
label={plugin.name}
label={pluginName(t, plugin)}
description={
plugin.channel === "beta"
? `v${plugin.version}`
+2 -1
View File
@@ -7,6 +7,7 @@
*/
import { PluginIcon } from "@/lib/plugin-icon";
import { pluginKey } from "@/lib/plugin-i18n";
import {
hostEditorSectionList,
registerHostEditorSection,
@@ -95,7 +96,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
pluginId: plugin.id,
group: placement?.group ?? host.editorGroup ?? "top",
order: placement?.order ?? host.editorOrder ?? 100,
labelKey: plugin.name,
labelKey: pluginKey(plugin.id, "plugin.name"),
label: plugin.name,
// Nothing but the plugin's host settings, so it works for defaults too.
defaults: ownSections.has(plugin.id) ? "only" : true,
+1 -1
View File
@@ -730,7 +730,7 @@ export function HostEditor({
<EditorSection
key={id}
id={id}
label={section.label ?? t(section.labelKey)}
label={t(section.labelKey, { defaultValue: section.label })}
>
<LazySection>
<Section
+6 -6
View File
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
pluginId?: string;
group: "top" | "ssh";
labelKey: string;
/** Already-translated label, used instead of labelKey when set. */
/** Shown when labelKey has no translation. */
label?: string;
icon?: ComponentType<{ className?: string }>;
/** 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<string, boolean> | 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 ? <Icon className="size-3" /> : 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<string, boolean>,
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<string, boolean>,
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 [
{
+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 { 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)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
+3 -1
View File
@@ -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];
+3 -2
View File
@@ -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) },
)}
</DropdownMenuItem>
)),
@@ -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) => (
<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 { 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(
<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", () => ({
useConfirm: () => vi.fn(async () => true),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const i18n = vi.hoisted(() => {
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() } }));
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" },
]);
});
@@ -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> = {}): 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();
});