feat: give plugins their own settings and one settings screen

This commit is contained in:
LukeGus committed 2026-09-22 18:31:19 -05:00
1 parent cc15559ea1
commit 49f0286a14
66 files changed
+36497 -451

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
/**
* 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. Phase B fills it as each feature
* moves its columns out of ssh_data.
*/
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } 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, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {
// Additive: the built-in host tabs do not depend on this.
});
return () => {
cancelled = true;
};
}, []);
return useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (host.fields.length > 0 || !!host.enableKey);
}),
[plugins],
);
}
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>
);
}
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 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 ? values[enableKey] === true : true;
return (
<SectionCard
title={plugin.name}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
>
{!running && (
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.hostPluginNotRunning", { name: plugin.name })}
</div>
)}
{enableKey && (
<SettingsFieldRow
pluginId={plugin.id}
field={{
key: enableKey,
type: "boolean",
labelKey: host.enableLabelKey,
}}
values={values}
setValue={setValue}
running={running}
/>
)}
{enabled &&
host.fields
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
/>
))}
</SectionCard>
);
}
+238
View File
@@ -0,0 +1,238 @@
/**
* One plugin's settings page, drawn from its manifest.
*
* The plugin declares groups and fields; Termix renders them with the same
* SectionCard and SettingRow everything else uses. A plugin cannot ship its
* own form styling and drift from the rest of the app, and a page disappears
* cleanly when the plugin is uninstalled.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
getPluginUserSettings,
updatePluginAdminSettings,
updatePluginUserSettings,
PluginSettingsValidationError,
type PluginSettingsErrors,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } from "./settings-fields-util";
interface PluginSettingsPageProps {
plugin: PluginSummary;
isAdmin: boolean;
}
type Scope = "admin" | "user";
export function PluginSettingsPage({
plugin,
isAdmin,
}: PluginSettingsPageProps) {
const { t } = useTranslation();
const settings = plugin.contributes?.settings;
const running = plugin.enabled && plugin.state !== "failed";
// Admin fields are not merely disabled for a non-admin: the values behind
// them are install-wide configuration they have no business reading.
const scopes = useMemo(() => {
const entries: { scope: Scope; fields: PluginSettingsField[] }[] = [];
if (isAdmin && (settings?.admin?.length ?? 0) > 0) {
entries.push({ scope: "admin", fields: settings!.admin! });
}
if ((settings?.user?.length ?? 0) > 0) {
entries.push({ scope: "user", fields: settings!.user! });
}
return entries;
}, [settings, isAdmin]);
if (scopes.length === 0) {
return (
<div className="flex flex-col gap-2 p-2.5">
<p className="text-xs text-muted-foreground px-1">
{t("settings.pluginNoSettings", { name: plugin.name })}
</p>
</div>
);
}
return (
<div className="flex flex-col gap-2 p-2.5">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.pluginStoppedNotice", { name: plugin.name })}
</div>
)}
{scopes.map(({ scope, fields }) => (
<PluginScopeForm
key={scope}
plugin={plugin}
scope={scope}
fields={fields}
running={running}
/>
))}
</div>
);
}
/**
* One scope's form.
*
* Admin and user settings are separate requests with separate permissions, so
* they save separately rather than sharing one dirty state.
*/
function PluginScopeForm({
plugin,
scope,
fields,
running,
}: {
plugin: PluginSummary;
scope: Scope;
fields: PluginSettingsField[];
running: boolean;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<PluginSettingsErrors>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const loaded =
scope === "admin"
? await getPluginAdminSettings(plugin.id)
: await getPluginUserSettings(plugin.id);
setValues(loaded);
setErrors({});
setDirty(false);
} catch {
toast.error(t("settings.pluginSettingsLoadFailed"));
} finally {
setLoading(false);
}
}, [plugin.id, scope, t]);
useEffect(() => {
void load();
}, [load]);
const setValue = (key: string, value: unknown) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const saved =
scope === "admin"
? await updatePluginAdminSettings(plugin.id, values)
: await updatePluginUserSettings(plugin.id, values);
setValues(saved);
setErrors({});
setDirty(false);
toast.success(t("settings.pluginSettingsSaved"));
} catch (error) {
if (error instanceof PluginSettingsValidationError) {
setErrors(error.errors);
toast.error(t("settings.pluginSettingsInvalid"));
} else {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
} finally {
setSaving(false);
}
};
// Fields keep their declared order within a group, and groups appear in the
// order their first field does, so a manifest controls the whole layout.
const groups = useMemo(() => {
const ordered: {
key: string | undefined;
fields: PluginSettingsField[];
}[] = [];
for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field);
else ordered.push({ key: field.group, fields: [field] });
}
return ordered;
}, [fields]);
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-4 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("common.loading")}
</div>
);
}
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return (
<>
{groups.map((group, index) => (
<SectionCard
key={group.key ?? `group-${index}`}
title={
group.key
? t(pluginKey(plugin.id, group.key))
: scope === "admin"
? t("settings.pluginGroupAdmin")
: t("settings.pluginGroupUser")
}
icon={icon}
>
{group.fields
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
))}
</SectionCard>
))}
<div className="flex justify-end px-1">
<Button
size="sm"
className="h-7 text-xs"
disabled={!dirty || saving}
onClick={() => void save()}
>
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
{t("common.save")}
</Button>
</div>
</>
);
}
+294
View File
@@ -0,0 +1,294 @@
/**
* One control per declared settings field type.
*
* A plugin supplies data; core draws the form. That is what keeps every
* plugin's settings page looking like the rest of the app, and what makes a
* page disappear cleanly when its plugin does.
*/
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/button";
import { FakeSwitch, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { pluginKey } from "@/lib/plugin-i18n";
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
import { getSettingsComponent } from "./settings-components";
export interface SettingsFieldProps {
pluginId: string;
field: PluginSettingsField;
values: Record<string, unknown>;
setValue: (key: string, value: unknown) => void;
/** Whether the owning plugin is running. */
running: boolean;
/** Message from a rejected save, shown under the control. */
error?: string;
disabled?: boolean;
}
export function SettingsFieldRow({
pluginId,
field,
values,
setValue,
running,
error,
disabled = false,
}: SettingsFieldProps) {
const { t } = useTranslation();
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
// A custom field draws its own row: it has no single control to put in the
// right-hand slot, and often no label of its own either.
if (field.type === "custom") {
const Component = getSettingsComponent(pluginId, field.component);
if (!Component) return null;
return (
<Component
pluginId={pluginId}
values={values}
setValue={setValue}
running={running}
/>
);
}
const value = values[field.key];
const readOnly = disabled || !running;
const description = field.descriptionKey ? (
<>
{label(field.descriptionKey)}
{error && <span className="block text-destructive mt-0.5">{error}</span>}
</>
) : error ? (
<span className="text-destructive">{error}</span>
) : undefined;
return (
<SettingRow
label={label(field.labelKey)}
description={description}
badge={error ? t("common.error") : undefined}
>
<FieldControl
pluginId={pluginId}
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
/>
</SettingRow>
);
}
function FieldControl({
pluginId,
field,
value,
setValue,
readOnly,
}: {
pluginId: string;
field: PluginSettingsField;
value: unknown;
setValue: (key: string, value: unknown) => void;
readOnly: boolean;
}) {
const { t } = useTranslation();
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
const placeholder = field.placeholderKey
? label(field.placeholderKey)
: undefined;
switch (field.type) {
case "boolean":
return (
<FakeSwitch
checked={value === true}
disabled={readOnly}
onChange={(next) => setValue(field.key, next)}
/>
);
case "number":
return (
<Input
type="number"
className="h-7 w-40 text-sm"
disabled={readOnly}
min={field.min}
max={field.max}
placeholder={placeholder}
value={value === undefined || value === null ? "" : String(value)}
onChange={(event) =>
setValue(
field.key,
event.target.value === "" ? null : Number(event.target.value),
)
}
/>
);
case "select":
return (
<Select
disabled={readOnly}
value={typeof value === "string" ? value : ""}
onValueChange={(next) => setValue(field.key, next)}
>
<SelectTrigger className="h-7 w-52 text-sm">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{label(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "multiselect": {
const selected = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="flex flex-col gap-1.5 items-end">
{(field.options ?? []).map((option) => (
<label
key={option.value}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<span>{label(option.labelKey)}</span>
<Checkbox
disabled={readOnly}
checked={selected.includes(option.value)}
onCheckedChange={(checked) =>
setValue(
field.key,
checked
? [...selected, option.value]
: selected.filter((entry) => entry !== option.value),
)
}
/>
</label>
))}
</div>
);
}
case "secret":
return (
<SecretControl
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
placeholder={placeholder}
/>
);
case "textarea":
case "json":
return (
<Textarea
className="w-64 text-sm font-mono min-h-16"
disabled={readOnly}
placeholder={placeholder}
value={
typeof value === "string"
? value
: value === undefined || value === null
? ""
: JSON.stringify(value, null, 2)
}
onChange={(event) => setValue(field.key, event.target.value)}
/>
);
default:
return (
<Input
className="h-7 w-52 text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
);
}
}
/**
* A secret is never sent to the browser, so the control shows whether one is
* stored and offers to replace or clear it. Leaving it untouched sends the
* redacted marker straight back, which the server reads as "no change".
*/
function SecretControl({
field,
value,
setValue,
readOnly,
placeholder,
}: {
field: PluginSettingsField;
value: unknown;
setValue: (key: string, value: unknown) => void;
readOnly: boolean;
placeholder?: string;
}) {
const { t } = useTranslation();
const redacted = isRedactedSecret(value);
const stored = redacted && value.set;
if (redacted) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{stored ? t("settings.secretSet") : t("settings.secretNotSet")}
</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={readOnly}
onClick={() => setValue(field.key, "")}
>
{stored ? t("settings.secretReplace") : t("common.edit")}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<PasswordInput
className="h-7 w-52 text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={readOnly}
onClick={() => setValue(field.key, { set: stored })}
>
{t("common.cancel")}
</Button>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Everything that configures the app rather than operating a server.
*
* Profile and admin were two separate rail panels, each a fixed accordion of
* hardcoded sections. That is the wrong shape for the number of sections they
* hold, and it left no place at all for a plugin to contribute one. This is a
* single screen with a banded left nav, and a plugin's page is an entry in it
* that appears and disappears with the plugin.
*
* Both rail buttons still open this, each on its own section, so the Navigation
* visibility toggles and the habit of reaching for either one keep working.
*/
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
Puzzle,
Settings as SettingsIcon,
User,
} from "lucide-react";
import { Separator } from "@/components/separator";
import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { PluginSettingsPage } from "./PluginSettingsPage";
import { registerLegacySettingsComponents } from "./legacy-settings-components";
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
// Taken from the panels themselves: this screen only forwards them, so
// restating their shapes here would be one more thing to keep in step.
type ProfileProps = ComponentProps<typeof UserProfilePanel>;
type AdminProps = ComponentProps<typeof AdminSettingsPanel>;
/** Built-in sections, plus "plugin:<id>" for a plugin's own settings page. */
export type SettingsSectionId = "profile" | "admin" | `plugin:${string}`;
export interface SettingsScreenProps {
/** Which page to land on. Both rail entries come in here. */
initialSection?: SettingsSectionId;
isAdmin?: boolean;
/** Forwarded to the profile panel. */
username?: ProfileProps["username"];
onLogout?: ProfileProps["onLogout"];
userPrefs?: ProfileProps["userPrefs"];
onPrefsChange?: ProfileProps["onPrefsChange"];
remoteSyncInitialServerUrl?: ProfileProps["remoteSyncInitialServerUrl"];
remoteSyncReconnectRequested?: ProfileProps["remoteSyncReconnectRequested"];
onRemoteSyncReconnectHandled?: ProfileProps["onRemoteSyncReconnectHandled"];
/** Forwarded to the admin panel. */
onEditingChange?: AdminProps["onEditingChange"];
onOpenHostTab?: AdminProps["onOpenHostTab"];
}
interface NavItem {
id: SettingsSectionId;
label: string;
icon: React.ElementType;
iconName?: string;
}
export function SettingsScreen({
initialSection = "profile",
isAdmin = false,
username,
onLogout,
userPrefs,
onPrefsChange,
remoteSyncInitialServerUrl,
remoteSyncReconnectRequested,
onRemoteSyncReconnectHandled,
onEditingChange,
onOpenHostTab,
}: SettingsScreenProps) {
const { t } = useTranslation();
const [section, setSection] = useState<SettingsSectionId>(initialSection);
const [navOpen, setNavOpen] = useState(false);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
// Registered here rather than at module scope so a custom field's component
// exists before any page that renders one. A7 moves this into the loader.
useEffect(() => {
registerLegacySettingsComponents();
}, []);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {
// The plugin band is additive: the core sections still work without it.
});
return () => {
cancelled = true;
};
}, []);
// The rail can switch which entry opened the screen while it is mounted.
useEffect(() => setSection(initialSection), [initialSection]);
/**
* A plugin gets a page when it is enabled and declares settings this user
* could actually change. A non-admin seeing a page of admin-only fields
* would be a page they can read and never act on.
*/
const pluginPages = useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const settings = plugin.contributes?.settings;
if (!settings) return false;
if ((settings.user?.length ?? 0) > 0) return true;
return isAdmin && (settings.admin?.length ?? 0) > 0;
}),
[plugins, isAdmin],
);
const bands = useMemo(() => {
const result: { heading: string; items: NavItem[] }[] = [
{
heading: t("settings.bandYou"),
items: [{ id: "profile", label: t("settings.account"), icon: User }],
},
];
if (pluginPages.length > 0) {
result.push({
heading: t("settings.bandPlugins"),
items: pluginPages.map((plugin) => ({
id: `plugin:${plugin.id}` as SettingsSectionId,
label: plugin.name,
icon: Puzzle,
iconName: plugin.icon,
})),
});
}
if (isAdmin) {
result.push({
heading: t("settings.bandInstance"),
items: [{ id: "admin", label: t("nav.admin"), icon: SettingsIcon }],
});
}
return result;
}, [t, pluginPages, isAdmin]);
const sections = bands.flatMap((band) => band.items);
const active = sections.find((item) => item.id === section) ?? sections[0];
// A plugin page can vanish under you: the plugin is disabled from the admin
// section in the same screen. Fall back rather than render an empty pane.
useEffect(() => {
if (
section.startsWith("plugin:") &&
!sections.some((s) => s.id === section)
) {
setSection("profile");
}
}, [section, sections]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="flex h-12.5 shrink-0 flex-row items-center border-b border-border">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
<SettingsIcon className="size-4 shrink-0 text-accent-brand" />
<span className="truncate text-base font-bold tracking-tight">
{t("settings.title")}
</span>
<Separator orientation="vertical" className="h-4" />
<span className="hidden truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:inline">
{active?.label}
</span>
{/* The nav column does not fit beside the content on a phone, so
there it collapses behind the section name. */}
<button
onClick={() => setNavOpen((open) => !open)}
aria-expanded={navOpen}
className="flex min-w-0 items-center gap-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:hidden"
>
<span className="truncate">{active?.label}</span>
<ChevronDown
className={`size-3 shrink-0 transition-transform ${navOpen ? "rotate-180" : ""}`}
/>
</button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<nav
className={`${navOpen ? "flex" : "hidden"} w-full shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 md:flex md:w-60`}
>
{bands.map((band) => (
<div key={band.heading} className="flex flex-col">
<span className="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{band.heading}
</span>
{band.items.map((item) => {
const Icon = item.icon;
const on = item.id === section;
return (
<button
key={item.id}
onClick={() => {
setSection(item.id);
setNavOpen(false);
}}
className={`flex w-full items-center gap-2 border-l-2 py-2.5 pl-2 pr-2 text-left transition-colors md:py-1.5 ${
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{item.iconName ? (
<PluginIcon
name={item.iconName}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{item.label}
</span>
</button>
);
})}
</div>
))}
</nav>
<div
className={`${navOpen ? "hidden" : ""} min-h-0 flex-1 overflow-y-auto md:block`}
>
<Suspense fallback={null}>
{section === "profile" && (
<UserProfilePanel
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={onRemoteSyncReconnectHandled}
/>
)}
{section === "admin" && isAdmin && (
<AdminSettingsPanel
onEditingChange={onEditingChange}
onOpenHostTab={onOpenHostTab}
/>
)}
{section.startsWith("plugin:") &&
(() => {
const plugin = pluginPages.find(
(candidate) => `plugin:${candidate.id}` === section,
);
if (!plugin) return null;
return <PluginSettingsPage plugin={plugin} isAdmin={isAdmin} />;
})()}
</Suspense>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
/**
* The tailnet status shown on the Tailscale settings page.
*
* Confirms the saved key actually works, which is the question anyone has just
* after entering one. Connecting to a device stays in the Tailscale rail panel;
* a settings page is for configuring, not for operating.
*
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
* bundle register it.
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/button";
import { pluginKey } from "@/lib/plugin-i18n";
import type { SettingsComponentProps } from "../settings-components";
import { getTailscaleDevices } from "../../../../plugins/tailscale/src/frontend/tailscale-api";
type Status =
| { kind: "loading" }
| { kind: "no-key" }
| { kind: "error" }
| { kind: "ok"; count: number };
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>({ kind: "loading" });
const load = useCallback(async () => {
if (!running) {
setStatus({ kind: "no-key" });
return;
}
setStatus({ kind: "loading" });
try {
const result = await getTailscaleDevices();
if (!result.hasApiKey) {
setStatus({ kind: "no-key" });
return;
}
if (result.error) {
setStatus({ kind: "error" });
return;
}
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
} catch {
setStatus({ kind: "error" });
}
}, [running]);
useEffect(() => {
void load();
}, [load]);
const label = (key: string) => t(pluginKey("tailscale", key));
return (
<div className="flex items-center justify-between gap-3 py-3">
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium leading-snug">
{label("settings.devices.label")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{status.kind === "loading" && label("settings.devices.checking")}
{status.kind === "no-key" && label("settings.devices.noKey")}
{status.kind === "error" && label("settings.devices.error")}
{status.kind === "ok" &&
t(pluginKey("tailscale", "settings.devices.count"), {
count: status.count,
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs shrink-0"
disabled={!running || status.kind === "loading"}
onClick={() => void load()}
>
{status.kind === "loading" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
</Button>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Registers the host editor's "Plugins" tab.
*
* Goes through the existing registerHostEditorTab seam rather than adding a
* case to HostTabId, so the tab genuinely appears and disappears with the
* plugins that fill it. Nothing is registered when no enabled plugin declares
* host settings, which is what stops an empty tab from showing up.
*
* Re-run whenever the plugin list changes: enabling a plugin from the settings
* screen has to put the tab there without a reload.
*/
import { Puzzle } from "lucide-react";
import {
registerHostEditorTab,
unregisterHostEditorTab,
} from "@/sidebar/HostManagerTabs";
import type { PluginSummary } from "@/api/plugins-api";
import {
HostPluginSections,
type HostPluginSettings,
} from "./HostPluginSections";
export const HOST_PLUGINS_TAB_ID = "plugins";
/** Only these two fields are read, so a test can pass a plain object. */
interface HostFormLike {
pluginSettings?: HostPluginSettings;
}
/**
* Adds or removes the tab to match the current plugin list.
*
* Returns whether the tab is now present, for a caller that needs to redirect
* away from it when the last contributor goes.
*/
export function syncHostPluginsTab(plugins: PluginSummary[]): boolean {
const contributors = plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (host.fields.length > 0 || !!host.enableKey);
});
if (contributors.length === 0) {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
return false;
}
registerHostEditorTab({
id: HOST_PLUGINS_TAB_ID,
labelKey: "settings.pluginsGroupLabel",
icon: <Puzzle className="size-3" />,
component: ({ form, setField }) => (
<HostPluginSections
plugins={contributors}
values={(form as HostFormLike)?.pluginSettings ?? {}}
setValue={(pluginId, key, value) => {
const current = (form as HostFormLike)?.pluginSettings ?? {};
setField("pluginSettings", {
...current,
[pluginId]: { ...(current[pluginId] ?? {}), [key]: value },
});
}}
/>
),
});
return true;
}
@@ -0,0 +1,27 @@
/**
* Core-side registration of plugin settings components.
*
* TEMPORARY. A `type: "custom"` settings field names a component the plugin's
* own frontend bundle should register through `app.registerSettingsComponent`.
* There is no frontend plugin loader yet, so core registers the handful that
* exist, the same way pluginLoader.ts mirrors the first-party action
* contributions.
*
* A7 deletes this file. Each plugin registers its own component from its
* bundle, and nothing in core names a plugin id.
*
* Only register what is needed now. Everything else waits for its own step:
* the AI providers list stays where it is until B18.
*/
import { registerSettingsComponent } from "./settings-components.js";
import { TailscaleDevicesStatus } from "./components/TailscaleDevicesStatus.js";
let registered = false;
export function registerLegacySettingsComponents(): void {
if (registered) return;
registered = true;
registerSettingsComponent("tailscale", "devices", TailscaleDevicesStatus);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Components behind a `type: "custom"` settings field.
*
* Most settings are expressible as a schema, and those are rendered from the
* manifest so a plugin cannot ship its own form styling. A few are not: a
* device browser, a provider list. Those declare a component id here instead,
* which keeps the escape hatch narrow and named rather than letting a plugin
* hand core arbitrary markup.
*
* A7 lets a plugin's own frontend bundle register these through
* `app.registerSettingsComponent`. Until then core registers them, in
* legacy-settings-components.ts.
*/
import type { ComponentType } from "react";
export interface SettingsComponentProps {
pluginId: string;
/** Current values for the scope this field sits in. */
values: Record<string, unknown>;
/** Writes one key in the same scope. */
setValue: (key: string, value: unknown) => void;
/** Whether the owning plugin is running. */
running: boolean;
}
export type SettingsComponent = ComponentType<SettingsComponentProps>;
/** Keyed "<pluginId>:<componentId>" so two plugins may use the same name. */
const registry = new Map<string, SettingsComponent>();
function registryKey(pluginId: string, componentId: string): string {
return `${pluginId}:${componentId}`;
}
export function registerSettingsComponent(
pluginId: string,
componentId: string,
component: SettingsComponent,
): () => void {
const key = registryKey(pluginId, componentId);
registry.set(key, component);
return () => {
if (registry.get(key) === component) registry.delete(key);
};
}
export function getSettingsComponent(
pluginId: string,
componentId: string | undefined,
): SettingsComponent | undefined {
if (!componentId) return undefined;
return registry.get(registryKey(pluginId, componentId));
}
export function unregisterSettingsComponents(pluginId: string): void {
for (const key of [...registry.keys()]) {
if (key.startsWith(`${pluginId}:`)) registry.delete(key);
}
}
/** Test helper. */
export function resetSettingsComponents(): void {
registry.clear();
}
+15
View File
@@ -0,0 +1,15 @@
import type { PluginSettingsField } from "@/api/plugins-api";
/**
* Whether a field's `requires` gate is satisfied.
*
* Kept out of the component file so both the settings page and the host editor
* section can import it without dragging a component along.
*/
export function isFieldActive(
field: PluginSettingsField,
values: Record<string, unknown>,
): boolean {
if (!field.requires) return true;
return values[field.requires] === true;
}