feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+6 -1
View File
@@ -22,7 +22,10 @@ import {
} from "@/components/select";
import { pluginKey } from "@/lib/plugin-i18n";
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
import { getSettingsComponent } from "./settings-components";
import {
getSettingsComponent,
useSettingsComponents,
} from "./settings-components";
export interface SettingsFieldProps {
pluginId: string;
@@ -46,6 +49,8 @@ export function SettingsFieldRow({
disabled = false,
}: SettingsFieldProps) {
const { t } = useTranslation();
// A custom component appears once its plugin's frontend has registered it.
useSettingsComponents();
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
-7
View File
@@ -24,7 +24,6 @@ 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) => ({
@@ -87,12 +86,6 @@ export function SettingsScreen({
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()
@@ -1,90 +0,0 @@
/**
* 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>
);
}
+11 -8
View File
@@ -1,9 +1,9 @@
/**
* 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
* Goes through the host editor section registry rather than a case in the
* editor, 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
@@ -12,8 +12,8 @@
import { Puzzle } from "lucide-react";
import {
registerHostEditorTab,
unregisterHostEditorTab,
registerHostEditorSection,
unregisterHostEditorSection,
} from "@/sidebar/HostManagerTabs";
import type { PluginSummary } from "@/api/plugins-api";
import {
@@ -42,14 +42,17 @@ export function syncHostPluginsTab(plugins: PluginSummary[]): boolean {
});
if (contributors.length === 0) {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
return false;
}
registerHostEditorTab({
registerHostEditorSection({
id: HOST_PLUGINS_TAB_ID,
// In the main strip, so hosts without SSH reach it too.
group: "top",
order: 100,
labelKey: "settings.pluginsGroupLabel",
icon: <Puzzle className="size-3" />,
icon: Puzzle,
component: ({ form, setField }) => (
<HostPluginSections
plugins={contributors}
@@ -1,27 +0,0 @@
/**
* 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);
}
+23 -15
View File
@@ -5,14 +5,12 @@
* 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.
* hand core arbitrary markup. A plugin registers them from its frontend
* through `app.registerSettingsComponent`.
*/
import type { ComponentType } from "react";
import { createRegistry } from "@/lib/registry";
export interface SettingsComponentProps {
pluginId: string;
@@ -27,7 +25,10 @@ export interface SettingsComponentProps {
export type SettingsComponent = ComponentType<SettingsComponentProps>;
/** Keyed "<pluginId>:<componentId>" so two plugins may use the same name. */
const registry = new Map<string, SettingsComponent>();
const registry = createRegistry<{
id: string;
component: SettingsComponent;
}>();
function registryKey(pluginId: string, componentId: string): string {
return `${pluginId}:${componentId}`;
@@ -38,11 +39,10 @@ export function registerSettingsComponent(
componentId: string,
component: SettingsComponent,
): () => void {
const key = registryKey(pluginId, componentId);
registry.set(key, component);
return () => {
if (registry.get(key) === component) registry.delete(key);
};
return registry.register({
id: registryKey(pluginId, componentId),
component,
});
}
export function getSettingsComponent(
@@ -50,16 +50,24 @@ export function getSettingsComponent(
componentId: string | undefined,
): SettingsComponent | undefined {
if (!componentId) return undefined;
return registry.get(registryKey(pluginId, componentId));
return registry.get(registryKey(pluginId, componentId))?.component;
}
/** Registered "<pluginId>:<componentId>" keys. */
export function listSettingsComponents(): string[] {
return registry.list().map((entry) => entry.id);
}
/** Re-renders when components are registered, e.g. after a plugin loads. */
export const useSettingsComponents = registry.useList;
export function unregisterSettingsComponents(pluginId: string): void {
for (const key of [...registry.keys()]) {
if (key.startsWith(`${pluginId}:`)) registry.delete(key);
for (const entry of registry.list()) {
if (entry.id.startsWith(`${pluginId}:`)) registry.unregister(entry.id);
}
}
/** Test helper. */
export function resetSettingsComponents(): void {
registry.clear();
registry.reset();
}