Files
kandev/apps/web/lib/plugins/registry.ts
T
João Salavisa 30e6cba810 feat(plugins): add owner-scoped plugin-settings slot for inline plugin UI (#1836)
* feat(plugins): add plugin-settings slot to per-plugin settings page

Add a named "plugin-settings" host slot so an installed plugin can render
its own inline UI (e.g. a live integration/health status card) on its
per-plugin settings page (Settings > Plugins > <plugin>), between the
schema-driven settings form and the manifest card.

The slot renders on every plugin's detail page, so the host forwards the
current plugin's identity via slotProps ({ pluginId, status }); registered
components must gate on slotProps.pluginId to avoid rendering on another
plugin's page. Documented in PLUGIN-API.md and mirrored in types.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(plugins): make plugin-settings slot owner-scoped in the host

Instead of forcing every plugin author to early-return unless
slotProps.pluginId matches its own id, the host now filters the
"plugin-settings" slot by the owning plugin. The registry already records
the owning pluginId per registration, so PluginSlot gains an optional
ownerPluginId prop backed by a new getSlotComponentsForPlugin() getter,
and the plugin-detail page passes the current plugin's id.

A plugin's settings card now renders only on its own settings page, with
no gating boilerplate in the plugin. Docs/types updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plugins): add plugin-settings slot usage mockups

Add illustrative mockups of the plugin-settings slot rendered on a plugin's
settings page (Provider Usage health card) plus a strip of possible usages
(healthy, degraded, and a generic integration), referenced from PLUGIN-API.md.
Clearly labeled as mockups, not real product screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): reset error boundary per owner in owner-scoped slots

Address review feedback on PR #1836:

- The PluginErrorBoundary key was `${name}-${index}`, identical across
  plugins for an owner-scoped slot. Navigating /settings/plugins/A -> B in
  the same settings shell (without remounting PluginSlot) reused A's errored
  boundary and silently hid B's healthy card. Include ownerPluginId in the
  key so each owner gets an independent boundary; add a regression test.
- Trim the verbose multi-line slot comment in plugin-detail to one line per
  the repo's comment convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plugins): document plugin-settings slot; drop mockup binaries

- Public docs: add the owner-scoped `plugin-settings` slot to the Named
  slots table and a "Plugin settings page" section in plugins-authoring.md,
  and mention inline status cards in plugins.md's per-plugin settings.
- Remove the illustrative mockup PNGs (and their PLUGIN-API.md embeds) that
  were committed to the branch — image binaries should not merge into main;
  PR screenshots belong on a throwaway media ref instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(plugins): condense owner-key boundary comment to one line

Address review nit — the repo prefers one short comment line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:53:43 +01:00

197 lines
6.3 KiB
TypeScript

/**
* Reactive singleton `PluginRegistry` (docs/plans/plugins/PLUGIN-API.md).
*
* Holds every registration made by every loaded plugin, tracks the owning
* pluginId so a disabled/uninstalled plugin can be bulk-revoked, and exposes
* a tiny external-store subscription so React components re-render on
* registration changes (`usePluginRegistry()`).
*
* `pluginRegistry.forPlugin(pluginId)` returns the exact `PluginRegistry`
* shape from the frozen contract (no pluginId param on the register*
* methods) — this is what `host.ts` passes into a plugin's `initialize()`.
*/
import { useSyncExternalStore } from "react";
import type {
NavItem,
PluginRegistry,
PluginRouteOptions,
SlotComponent,
WsHandler,
} from "./types";
import type { ComponentType } from "react";
interface Owned<T> {
pluginId: string;
value: T;
}
export interface RouteRegistration {
path: string;
Component: ComponentType;
options?: PluginRouteOptions;
}
/** Route registration plus the owning pluginId — what `getRoutes()` returns. */
export interface PluginRouteRegistration extends RouteRegistration {
pluginId: string;
}
interface SlotRegistration {
slot: string;
Component: SlotComponent;
}
interface WsHandlerRegistration {
action: string;
handler: WsHandler;
}
function removeByPlugin<T>(list: Owned<T>[], pluginId: string): Owned<T>[] {
return list.filter((entry) => entry.pluginId !== pluginId);
}
class PluginRegistryStore {
private routes: Owned<RouteRegistration>[] = [];
private settingsRoutes: Owned<RouteRegistration>[] = [];
private navItems: Owned<NavItem>[] = [];
private slotComponents: Owned<SlotRegistration>[] = [];
private wsHandlers: Owned<WsHandlerRegistration>[] = [];
/** Display names from the boot payload, used for derived page-chrome titles. */
private pluginNames = new Map<string, string>();
private listeners = new Set<() => void>();
private version = 0;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getVersion = (): number => this.version;
registerRoute(
pluginId: string,
path: string,
Component: ComponentType,
options?: PluginRouteOptions,
): void {
this.routes.push({ pluginId, value: { path, Component, options } });
this.notify();
}
registerSettingsRoute(pluginId: string, path: string, Component: ComponentType): void {
this.settingsRoutes.push({ pluginId, value: { path, Component } });
this.notify();
}
registerNavItem(pluginId: string, item: NavItem): void {
this.navItems.push({ pluginId, value: item });
this.notify();
}
registerComponent(pluginId: string, slot: string, Component: SlotComponent): void {
this.slotComponents.push({ pluginId, value: { slot, Component } });
this.notify();
}
registerWsHandler(pluginId: string, action: string, handler: WsHandler): void {
this.wsHandlers.push({ pluginId, value: { action, handler } });
this.notify();
}
/** Bulk-revoke every registration owned by `pluginId` (disable/uninstall). */
unregisterPlugin(pluginId: string): void {
const before = this.totalCount();
this.routes = removeByPlugin(this.routes, pluginId);
this.settingsRoutes = removeByPlugin(this.settingsRoutes, pluginId);
this.navItems = removeByPlugin(this.navItems, pluginId);
this.slotComponents = removeByPlugin(this.slotComponents, pluginId);
this.wsHandlers = removeByPlugin(this.wsHandlers, pluginId);
this.pluginNames.delete(pluginId);
if (this.totalCount() !== before) this.notify();
}
getRoutes(): PluginRouteRegistration[] {
return this.routes.map((entry) => ({ ...entry.value, pluginId: entry.pluginId }));
}
/** Display name recorded by `forPlugin` (boot payload `ActivePlugin.name`). */
getPluginName(pluginId: string): string | undefined {
return this.pluginNames.get(pluginId);
}
getSettingsRoutes(): RouteRegistration[] {
return this.settingsRoutes.map((entry) => entry.value);
}
getNavItems(): NavItem[] {
return this.navItems.map((entry) => entry.value);
}
getSlotComponents(slot: string): SlotComponent[] {
return this.slotComponents
.filter((entry) => entry.value.slot === slot)
.map((entry) => entry.value.Component);
}
/**
* Slot components for `slot` registered by `pluginId` only. Used by
* owner-scoped slots (e.g. "plugin-settings") that render on a specific
* plugin's own surface, so the host filters by owner instead of making
* every plugin author gate on the current plugin id.
*/
getSlotComponentsForPlugin(slot: string, pluginId: string): SlotComponent[] {
return this.slotComponents
.filter((entry) => entry.value.slot === slot && entry.pluginId === pluginId)
.map((entry) => entry.value.Component);
}
getWsHandlers(action: string): WsHandler[] {
return this.wsHandlers
.filter((entry) => entry.value.action === action)
.map((entry) => entry.value.handler);
}
/** Registry view scoped to one plugin — matches the frozen `PluginRegistry` contract. */
forPlugin(pluginId: string, pluginName?: string): PluginRegistry {
if (pluginName) this.pluginNames.set(pluginId, pluginName);
return {
registerRoute: (path, Component, options) =>
this.registerRoute(pluginId, path, Component, options),
registerNavItem: (item) => this.registerNavItem(pluginId, item),
registerSettingsRoute: (path, Component) =>
this.registerSettingsRoute(pluginId, path, Component),
registerComponent: (slot, Component) => this.registerComponent(pluginId, slot, Component),
registerWsHandler: (action, handler) => this.registerWsHandler(pluginId, action, handler),
};
}
private totalCount(): number {
return (
this.routes.length +
this.settingsRoutes.length +
this.navItems.length +
this.slotComponents.length +
this.wsHandlers.length
);
}
private notify(): void {
this.version += 1;
this.listeners.forEach((listener) => listener());
}
}
export const pluginRegistry = new PluginRegistryStore();
/** Snapshot hook: re-renders the caller whenever any plugin registration changes. */
export function usePluginRegistry(): PluginRegistryStore {
useSyncExternalStore(
pluginRegistry.subscribe,
pluginRegistry.getVersion,
pluginRegistry.getVersion,
);
return pluginRegistry;
}