adad46fab1
* feat(plugins): richer frontend SDK — page chrome, nav icons, more host UI
Give native UI plugins the primitives to render kandev-native pages while
keeping everything configurable:
- registerRoute gains an options arg; plugin routes get a first-party
PageTopbar title bar by default (title/icon derived from the nav item for
the same path, or set via { topbar: { title, subtitle, icon, backHref,
backLabel, actions } }). Pass { topbar: false } to render full-bleed and
own the chrome.
- NavItem.icon now resolves against a curated name map (lib/plugins/icons.ts);
unknown/missing names fall back to the puzzle glyph.
- host.ui expands to the primitives a native-feeling page needs (Dialog,
DropdownMenu, Pagination, Sheet, Select, Tabs, Table, Tooltip, ...) plus
first-party PageTopbar, Combobox, and TaskCreateDialog. Plugins must use
these host instances — a bundled second copy of any Radix/portal component
splits React context.
- host.navigate(href, { replace? }) for soft SPA navigation into native
routes, and host.api.baseUrl for reaching first-party REST without
re-deriving the split-origin backend URL.
The registry records each plugin's display name for derived titles and
getRoutes() entries now carry pluginId + options. Contract doc
(PLUGIN-API.md) updated to match.
* fix(plugins): expose full Sheet subcomponent family on host.ui
host.ui advertised Sheet but only exposed Sheet/Content/Header/Title, so
plugins (which must use host instances for portal/context components)
couldn't build an uncontrolled sheet — no Trigger/Close/Footer/Description.
Add the missing four for parity with the Dialog family.
* fix(plugins): clear plugin display name on unregisterPlugin
unregisterPlugin bulk-revoked every other registration but left the entry
in pluginNames, so getPluginName returned a stale name after disable and
names leaked across enable/disable cycles. Delete the name too, with a
regression test.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
185 lines
5.8 KiB
TypeScript
185 lines
5.8 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);
|
|
}
|
|
|
|
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;
|
|
}
|