Files
Termix/src/ui/settings/HostPluginSections.tsx
T
ZacharyZcRandZacharyZcR 764d034098 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>
2026-10-16 23:09:50 -05:00

190 lines
5.8 KiB
TypeScript

/**
* Host editor sections contributed by plugins.
*
* A plugin declares host-scope fields in its manifest and core draws them, so
* a plugin cannot ship its own form styling and a section disappears when its
* plugin does. The enable switch comes first and gates the rest, which is the
* shape the hardcoded feature tabs already have.
*
* Nothing is rendered when no enabled plugin declares host settings, so the
* group does not appear as an empty tab.
*/
import { useMemo } from "react";
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";
import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
/** Values for every plugin on one host: { [pluginId]: { [key]: value } }. */
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/** Enabled plugins declaring host-scope settings. */
export function usePluginHostSections(): PluginSummary[] {
const plugins = useInstalledPlugins();
return useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (hasVisibleFields(host.fields) || !!host.enableKey);
}),
[plugins],
);
}
/** What each field reads before the host saves it. */
function hostFieldDefaults(plugin: PluginSummary): Record<string, unknown> {
const host = plugin.contributes?.settings?.host;
if (!host) return {};
const defaults: Record<string, unknown> = {};
if (host.enableKey) defaults[host.enableKey] = host.enableDefault ?? false;
for (const field of host.fields) {
if (field.default !== undefined) defaults[field.key] = field.default;
}
return defaults;
}
export interface HostPluginSectionsProps {
plugins: PluginSummary[];
values: HostPluginSettings;
setValue: (pluginId: string, key: string, value: unknown) => void;
}
export function HostPluginSections({
plugins,
values,
setValue,
}: HostPluginSectionsProps) {
if (plugins.length === 0) return null;
return (
<div className="flex flex-col gap-2">
{plugins.map((plugin) => (
<HostPluginSection
key={plugin.id}
plugin={plugin}
values={values[plugin.id] ?? {}}
setValue={(key, value) => setValue(plugin.id, key, value)}
/>
))}
</div>
);
}
export interface HostFeatureFieldsProps {
// The host editor form, as handed to a host editor section.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
updateForm: (
patch: (form: Record<string, unknown>) => Record<string, unknown>,
) => void;
}
/**
* The calling plugin's manifest host settings, for a plugin that registered
* its own host editor section and so gets no generated tab.
*/
export function HostFeatureFields({
form,
updateForm,
}: HostFeatureFieldsProps) {
const pluginId = usePluginScope();
const plugin = usePluginHostSections().find((p) => p.id === pluginId);
if (!plugin) return null;
const values = (form?.pluginSettings as HostPluginSettings | undefined) ?? {};
return (
<HostPluginSection
plugin={plugin}
values={values[plugin.id] ?? {}}
setValue={(key, value) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as HostPluginSettings;
return {
...current,
pluginSettings: {
...all,
[plugin.id]: { ...(all[plugin.id] ?? {}), [key]: value },
},
};
})
}
/>
);
}
function HostPluginSection({
plugin,
values,
setValue,
}: {
plugin: PluginSummary;
values: Record<string, unknown>;
setValue: (key: string, value: unknown) => void;
}) {
const { t } = useTranslation();
const host = plugin.contributes?.settings?.host;
if (!host) return null;
const shown = { ...hostFieldDefaults(plugin), ...values };
const running = plugin.enabled && plugin.state !== "failed";
const enableKey = host.enableKey;
// With no enable switch the section is always on, which is what a plugin
// declaring only plain fields means.
const enabled = enableKey ? shown[enableKey] === true : true;
return (
<SectionCard
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: pluginName(t, plugin),
})}
</div>
)}
{enableKey && (
<SettingsFieldRow
pluginId={plugin.id}
field={{
key: enableKey,
type: "boolean",
labelKey: host.enableLabelKey,
descriptionKey: host.enableDescriptionKey,
}}
values={shown}
setValue={setValue}
running={running}
defaultKey={`${plugin.id}.${enableKey}`}
/>
)}
{enabled &&
host.fields
.filter((field) => isFieldShown(field, shown))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={shown}
setValue={setValue}
running={running}
defaultKey={`${plugin.id}.${field.key}`}
/>
))}
</SectionCard>
);
}