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

@@ -0,0 +1,65 @@
import { Puzzle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
/**
* Stands in for a tab, panel or card whose plugin is not running.
*
* A saved tab of a plugin that was switched off or removed must neither crash
* the shell nor vanish without a word, so it renders this until the plugin is
* back, at which point the real view takes over in place.
*/
export function PluginViewPlaceholder({
kind,
viewId,
compact = false,
}: {
kind: ViewKind;
viewId: string;
compact?: boolean;
}) {
const { t } = useTranslation();
// Subscribing re-renders this when plugin state changes.
usePluginStore();
const { status, owner } = unregisteredViewStatus(kind, viewId);
const name = owner?.summary.name ?? viewId;
if (status === "loading") {
return (
<div
className="flex h-full w-full items-center justify-center"
aria-label={t("plugins.runtime.loading", { name })}
>
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
const title =
status === "failed"
? t("plugins.runtime.failed", { name })
: owner
? t("plugins.runtime.needsPlugin", { name })
: t("plugins.runtime.needsUnknownPlugin");
const hint =
status === "failed"
? t("plugins.runtime.failedHint")
: status === "disabled"
? t("plugins.runtime.disabledHint")
: t("plugins.runtime.missingHint");
return (
<div
data-testid="plugin-view-placeholder"
data-status={status}
className={`flex h-full w-full flex-col items-center justify-center gap-2 text-center ${compact ? "p-3" : "p-6"}`}
>
<Puzzle className="size-6 text-muted-foreground/40" />
<span className="text-sm font-semibold text-muted-foreground">
{title}
</span>
<span className="text-xs text-muted-foreground/70">{hint}</span>
</div>
);
}
+377
View File
@@ -0,0 +1,377 @@
import type { ComponentType } from "react";
import type {
Disposer,
PluginManifest,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { PluginContributions } from "@/api/plugins-api";
import { registerRailItem } from "@/sidebar/rail-items";
import { registerTabType, type TabRenderProps } from "@/shell/tab-registry";
import { registerPanel, type PanelRenderProps } from "@/shell/panel-registry";
import {
registerHostEditorSection,
type HostEditorSectionRenderProps,
} from "@/sidebar/HostManagerTabs";
import {
registerHostAction,
registerHostBadge,
registerHostContextMenuItem,
} from "@/sidebar/host-contributions";
import { registerPaletteEntry } from "@/shell/palette-registry";
import {
registerDashboardCard,
type DashboardCardRenderProps,
} from "@/dashboard/dashboard-cards-registry";
import { registerWidget } from "@/features/homepage/widgets/WidgetRegistry";
import { registerSettingsComponent } from "@/settings/settings-components";
import {
declareActionSlot,
invokeAction,
registerAction,
registerSlotContribution,
} from "@/shell/action-registry";
import { pluginWsUrl } from "@/lib/plugin-transport";
import type { WidgetTypeDefinition } from "@/types/homepage-types";
import type { LucideIcon } from "lucide-react";
import {
registerLoginMethod,
registerSecondFactor,
registerSshAuthEditor,
} from "./auth-registry";
import { pluginHostBridge, resolvePluginPermission } from "./bridge";
import { shell, tabsApi } from "./shell-bridge";
import { withPluginScope } from "./scope";
import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n";
export interface PluginAppHandle {
app: TermixApp;
/** Runs every disposer, newest first, isolating failures. */
dispose: () => void;
}
/**
* Builds the object a plugin's `activate(app)` receives.
*
* Every registration goes into one bag, so disabling the plugin removes all
* of it whatever the plugin's own `deactivate` does. Components are wrapped
* in the plugin's scope as they are registered, which is what gives the SDK
* hooks their plugin id and keeps a crash inside the plugin's own box.
*/
export function createPluginApp(
pluginId: string,
manifest: PluginManifest,
contributes: PluginContributions | null,
options: { guest?: boolean } = {},
): PluginAppHandle {
const bag: Disposer[] = [];
let disposed = false;
const track = (disposer: Disposer): Disposer => {
bag.push(disposer);
return () => {
const index = bag.indexOf(disposer);
if (index >= 0) bag.splice(index, 1);
disposer();
};
};
const scoped = <P extends object>(component: ComponentType<P>) =>
withPluginScope(pluginId, component);
const key = (value: string) => pluginKey(pluginId, value);
const requireDeclared = (kind: ViewKind, id: string, what: string) => {
if (!manifestDeclares(contributes, kind, id)) {
const field =
kind === "tab"
? "contributes.tabs"
: kind === "panel"
? "contributes.panels or contributes.tabs"
: "contributes.dashboardCards";
throw new Error(
`${pluginId}: ${what} "${id}" is not declared in manifest ${field}`,
);
}
};
const app: TermixApp = {
pluginId,
manifest,
guest: !!options.guest,
registerRailItem(item) {
requireDeclared("panel", item.id, "rail item");
return track(
registerRailItem({
id: item.id,
icon: item.icon as unknown as LucideIcon,
labelKey: key(item.titleKey),
kind: item.kind === "tab" ? "tab" : undefined,
hideable: item.hideable,
promotable: item.promotable,
rightDockable: item.rightDockable,
mobilePrimary: item.mobilePrimary,
electronOnly: item.electronOnly,
separatorAfter: item.separatorAfter ?? true,
hidden: item.hidden,
after: item.after,
order: item.order,
pluginId,
}),
);
},
registerPanel(id, component, options) {
requireDeclared("panel", id, "panel");
return track(
registerPanel({
id,
pluginId,
component: scoped(
component as unknown as ComponentType<PanelRenderProps>,
),
keepMounted: options?.keepMounted,
}),
);
},
registerTab(type, component, options = {}) {
requireDeclared("tab", type, "tab type");
return track(
registerTabType({
id: type,
pluginId,
component: scoped(
component as unknown as ComponentType<TabRenderProps>,
),
icon: options.icon,
titleKey: options.titleKey ? key(options.titleKey) : undefined,
requiresHost: options.requiresHost,
noHostMessageKey: options.noHostMessageKey
? key(options.noHostMessageKey)
: undefined,
persistent: options.persistent,
singleton: options.singleton,
session: options.session,
hostless: options.hostless,
restore: options.restore as never,
activityTypes: options.activityTypes,
standalone: options.standalone
? scoped(options.standalone)
: undefined,
standaloneViews: options.standaloneViews,
panelFrame: options.panelFrame,
inLayouts: options.inLayouts,
preload: options.preload,
}),
);
},
registerHostEditorSection(section) {
return track(
registerHostEditorSection({
id: section.id,
pluginId,
group: section.group,
labelKey: key(section.titleKey),
icon: section.icon,
order: section.order,
visible: section.visible,
component: scoped(
section.component as unknown as ComponentType<HostEditorSectionRenderProps>,
),
}),
);
},
registerHostAction(action) {
return track(
registerHostAction({
...action,
titleKey: key(action.titleKey),
pluginId,
when: action.when as never,
run: action.run as never,
label: action.label as never,
items: action.items as never,
}),
);
},
registerHostBadge(badge) {
return track(
registerHostBadge({
id: badge.id,
pluginId,
when: badge.when as never,
component: scoped(badge.component) as never,
}),
);
},
registerHostContextMenuItem(item) {
return track(
registerHostContextMenuItem({
...item,
titleKey: key(item.titleKey),
pluginId,
when: item.when as never,
run: item.run as never,
}),
);
},
registerPaletteEntry(entry) {
return track(
registerPaletteEntry({
...entry,
titleKey: key(entry.titleKey),
pluginId,
when: entry.when as never,
run: entry.run as never,
}),
);
},
registerDashboardCard(card) {
requireDeclared("card", card.id, "dashboard card");
return track(
registerDashboardCard({
id: card.id,
pluginId,
titleKey: key(card.titleKey),
defaultHeight: card.defaultHeight,
component: scoped(
card.component as unknown as ComponentType<DashboardCardRenderProps>,
),
}),
);
},
registerHomepageWidget(widget) {
return track(
registerWidget({
...widget,
component: scoped(widget.component),
editFormComponent: widget.editFormComponent
? scoped(widget.editFormComponent)
: undefined,
} as unknown as WidgetTypeDefinition),
);
},
registerSettingsComponent(componentId, component) {
return track(
registerSettingsComponent(pluginId, componentId, scoped(component)),
);
},
registerAction(id, handler, options = {}) {
return track(
registerAction(id, handler, {
permission: options.permission
? resolvePluginPermission(pluginId, options.permission)
: undefined,
pluginId,
}),
);
},
declareActionSlot(slot) {
return track(declareActionSlot(slot));
},
registerSlotContribution(slotId, contribution) {
return track(
registerSlotContribution(slotId, {
...contribution,
titleKey: key(contribution.titleKey),
descriptionKey: contribution.descriptionKey
? key(contribution.descriptionKey)
: undefined,
component: contribution.component
? scoped(contribution.component)
: undefined,
pluginId,
}),
);
},
invokeAction: (id, ...args) => invokeAction(id, ...args),
registerSshAuthEditor(editor) {
return track(
registerSshAuthEditor({
id: editor.authType,
pluginId,
titleKey: key(editor.titleKey),
hintKey: editor.hintKey ? key(editor.hintKey) : undefined,
component: editor.component
? (scoped(editor.component) as never)
: undefined,
}),
);
},
registerLoginMethod(method) {
return track(
registerLoginMethod({
...method,
titleKey: key(method.titleKey),
pluginId,
component: scoped(method.component),
}),
);
},
registerSecondFactorUI(factor) {
return track(
registerSecondFactor({
...factor,
titleKey: key(factor.titleKey),
pluginId,
component: scoped(factor.component),
}),
);
},
api: pluginHostBridge.getApi(pluginId),
wsUrl: (path, options) =>
pluginWsUrl(pluginId, path, options as never) as ReturnType<
TermixApp["wsUrl"]
>,
tabs: {
...tabsApi,
openTab: shell.openTab as TermixApp["tabs"]["openTab"],
openSingletonTab: shell.openSingletonTab,
closeTab: shell.closeTab,
onChange: (listener) => track(tabsApi.onChange(listener)),
onReady: (listener) => track(tabsApi.onReady(listener)),
},
onDispose(dispose) {
if (disposed) {
dispose();
return;
}
bag.push(dispose);
},
};
return {
app,
dispose() {
disposed = true;
while (bag.length > 0) {
const disposer = bag.pop()!;
try {
disposer();
} catch (error) {
console.error(`[plugins] ${pluginId}: a disposer threw`, error);
}
}
},
};
}
+59
View File
@@ -0,0 +1,59 @@
import type { ComponentType } from "react";
import { createRegistry } from "@/lib/registry";
/**
* Auth UI plugins contribute.
*
* SSH auth editors are live: the host editor lists registered auth types next
* to the core ones (password, key, credential, agent, none) and renders the
* plugin's editor when one is picked. Login methods and second factors are
* declared here so the app object is complete; A8 wires them into the login
* screen.
*/
export interface SshAuthEditorDef {
/** The authType value stored on the host. */
id: string;
pluginId?: string;
titleKey: string;
hintKey?: string;
component?: ComponentType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
setField: (key: string, value: unknown) => void;
}>;
}
export interface LoginMethodDef {
id: string;
pluginId?: string;
titleKey: string;
icon?: ComponentType<{ className?: string }>;
component: ComponentType<Record<string, unknown>>;
}
export interface SecondFactorDef {
id: string;
pluginId?: string;
titleKey: string;
component: ComponentType<Record<string, unknown>>;
}
const sshAuthEditors = createRegistry<SshAuthEditorDef>();
const loginMethods = createRegistry<LoginMethodDef>();
const secondFactors = createRegistry<SecondFactorDef>();
export const registerSshAuthEditor = sshAuthEditors.register;
export const getSshAuthEditor = sshAuthEditors.get;
export const useSshAuthEditors = sshAuthEditors.useList;
export const registerLoginMethod = loginMethods.register;
export const useLoginMethods = loginMethods.useList;
export const registerSecondFactor = secondFactors.register;
export const useSecondFactors = secondFactors.useList;
export function resetAuthRegistries(): void {
sshAuthEditors.reset();
loginMethods.reset();
secondFactors.reset();
}
+251
View File
@@ -0,0 +1,251 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation as useI18nTranslation } from "react-i18next";
import { toast } from "sonner";
import {
__setPluginHost,
type PluginHostBridge,
type PluginHostRecord,
type SettingsScope,
type SettingsState,
} from "@termix/plugin-sdk/frontend";
import { usePermissions } from "@/hooks/use-permissions";
import { useTheme } from "@/components/theme-provider";
import { createPluginApi } from "@/lib/plugin-transport";
import {
getPluginAdminSettings,
getPluginHostSettings,
getPluginUserSettings,
updatePluginAdminSettings,
updatePluginHostSettings,
updatePluginUserSettings,
} from "@/api/plugins-api";
import { getUserInfo } from "@/main-axios";
import { usePluginScope } from "./scope";
import { knownPluginIds } from "./plugin-store";
import { tabsApi, useShellHosts } from "./shell-bridge";
import { invokeAction } from "@/shell/action-registry";
/**
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
* manifest module, which is not imported here because it pulls semver into
* the browser bundle.
*/
const CORE_PERMISSION_GROUPS = ["hosts", "snippets", "credentials", "admin"];
/**
* Resolves a permission the way the backend's ctx.rbac does: a short name
* belongs to the plugin, while a full id in a core group or another plugin's
* namespace is taken as given.
*/
export function resolvePluginPermission(
pluginId: string,
permission: string,
): string {
if (permission.startsWith(`${pluginId}.`)) return permission;
const head = permission.split(".")[0];
if (
permission.includes(".") &&
(CORE_PERMISSION_GROUPS.includes(head) ||
(head !== pluginId && knownPluginIds().includes(head)))
) {
return permission;
}
return `${pluginId}.${permission}`;
}
const apiClients = new Map<string, ReturnType<typeof createPluginApi>>();
function getApi(pluginId: string) {
let client = apiClients.get(pluginId);
if (!client) {
client = createPluginApi(pluginId);
apiClients.set(pluginId, client);
}
return client;
}
let currentUser: Promise<{
userId: string;
username: string;
isAdmin: boolean;
} | null> | null = null;
function loadCurrentUser() {
if (!currentUser) {
currentUser = getUserInfo()
.then((info) => ({
userId: info.userId,
username: info.username,
isAdmin: !!info.is_admin,
}))
.catch(() => {
currentUser = null;
return null;
});
}
return currentUser;
}
/** Forgets the cached user, on logout. */
export function resetBridgeUser(): void {
currentUser = null;
}
const SETTINGS_READERS: Record<
SettingsScope,
(pluginId: string, hostId?: number) => Promise<Record<string, unknown>>
> = {
admin: (pluginId) => getPluginAdminSettings(pluginId),
user: (pluginId) => getPluginUserSettings(pluginId),
host: (pluginId, hostId) => getPluginHostSettings(pluginId, hostId!),
};
const SETTINGS_WRITERS: Record<
SettingsScope,
(
pluginId: string,
values: Record<string, unknown>,
hostId?: number,
) => Promise<Record<string, unknown>>
> = {
admin: (pluginId, values) => updatePluginAdminSettings(pluginId, values),
user: (pluginId, values) => updatePluginUserSettings(pluginId, values),
host: (pluginId, values, hostId) =>
updatePluginHostSettings(pluginId, hostId!, values),
};
function toHostRecord(host: unknown): PluginHostRecord {
return host as PluginHostRecord;
}
export const pluginHostBridge: PluginHostBridge = {
usePluginId() {
const pluginId = usePluginScope();
if (!pluginId) {
throw new Error(
"SDK hooks can only be used inside a component a plugin registered",
);
}
return pluginId;
},
useTranslation(pluginId) {
const { t, i18n } = useI18nTranslation(pluginId);
return {
t: t as unknown as (
key: string,
options?: Record<string, unknown> | string,
) => string,
language: i18n.language,
};
},
usePermission(pluginId, permission) {
const { has, loaded } = usePermissions();
return loaded && has(resolvePluginPermission(pluginId, permission));
},
useSettings(pluginId, scope, hostId) {
const numericHostId =
hostId === undefined ? undefined : Number.parseInt(String(hostId), 10);
const [state, setState] = useState<{
values: Record<string, unknown>;
loaded: boolean;
}>({ values: {}, loaded: false });
useEffect(() => {
let cancelled = false;
if (scope === "host" && !Number.isFinite(numericHostId)) return;
SETTINGS_READERS[scope](pluginId, numericHostId)
.then((values) => {
if (!cancelled) setState({ values, loaded: true });
})
.catch(() => {
if (!cancelled) setState((prev) => ({ ...prev, loaded: true }));
});
return () => {
cancelled = true;
};
}, [pluginId, scope, numericHostId]);
const save = useCallback(
async (values: Record<string, unknown>) => {
const saved = await SETTINGS_WRITERS[scope](
pluginId,
values,
numericHostId,
);
setState({ values: saved, loaded: true });
},
[pluginId, scope, numericHostId],
);
return useMemo<SettingsState>(() => ({ ...state, save }), [state, save]);
},
useHost(hostId) {
const { hosts } = useShellHosts();
return useMemo(() => {
if (hostId === undefined || hostId === null) return null;
const found = hosts.find((host) => host.id === String(hostId));
return found ? toHostRecord(found) : null;
}, [hosts, hostId]);
},
useHosts() {
const { hosts, loaded } = useShellHosts();
return useMemo(
() => ({ hosts: hosts.map(toHostRecord), loaded }),
[hosts, loaded],
);
},
useCurrentUser() {
const [user, setUser] = useState<{
userId: string;
username: string;
isAdmin: boolean;
} | null>(null);
useEffect(() => {
let cancelled = false;
loadCurrentUser().then((next) => {
if (!cancelled) setUser(next);
});
return () => {
cancelled = true;
};
}, []);
return user;
},
useTheme() {
const { theme } = useTheme();
if (theme === "light") return { theme: "light" };
if ((theme as string) === "system" && typeof window !== "undefined") {
return {
theme: window.matchMedia?.("(prefers-color-scheme: light)").matches
? "light"
: "dark",
};
}
return { theme: "dark" };
},
toast: {
success: (message) => toast.success(message),
error: (message) => toast.error(message),
info: (message) => toast.info(message),
warning: (message) => toast.warning(message),
},
getApi: (pluginId) => getApi(pluginId),
useTabs: () => tabsApi,
invokeAction: (id, ...args) => invokeAction(id, ...args),
};
/** Installs the bridge. Idempotent. */
export function installPluginHostBridge(): void {
__setPluginHost(pluginHostBridge);
}
+404
View File
@@ -0,0 +1,404 @@
import type { ResourceKey } from "i18next";
import type {
FrontendModule,
PluginManifest,
} from "@termix/plugin-sdk/frontend";
import i18n, { setPluginLocaleResolver } from "@/i18n/i18n";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { getBackendUrl } from "@/main-axios";
import { createPluginApp, type PluginAppHandle } from "./app";
import { installPluginHostBridge } from "./bridge";
import {
getPluginRecord,
getPluginStoreState,
markPluginsSettled,
setFrontendState,
setPluginSummaries,
} from "./plugin-store";
import { workspaceFrontends, workspaceLocales } from "./workspace-plugins";
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
/**
* Loads plugin frontends into the running shell.
*
* After login, and whenever plugin state may have changed, it fetches
* GET /plugins and reconciles: plugins that are now enabled are imported and
* activated in dependency order, plugins that are no longer enabled are
* deactivated and everything they registered is removed. No reload either way.
*
* A plugin whose import or activate throws is marked failed and cleaned up;
* the others carry on.
*/
interface ActivePlugin {
id: string;
assetVersion: string | null;
handle: PluginAppHandle;
module: FrontendModule;
cssLink: HTMLLinkElement | null;
}
export interface PluginLoaderDeps {
fetchPlugins: () => Promise<PluginSummary[]>;
importFrontend: (summary: PluginSummary) => Promise<FrontendModule>;
loadLocale: (
summary: PluginSummary,
file: string,
) => Promise<ResourceKey | null>;
/** Adds the plugin stylesheet; returns the element to remove later. */
injectCss: (summary: PluginSummary) => HTMLLinkElement | null;
}
const active = new Map<string, ActivePlugin>();
/** Bundle version that failed, so the same broken bundle is not retried. */
const failedVersions = new Map<string, string | null>();
let queue: Promise<void> = Promise.resolve();
let deps: PluginLoaderDeps | null = null;
let started = false;
let guestMode = false;
function assetUrl(summary: PluginSummary, file: string): string {
const version = summary.assetVersion
? `?v=${encodeURIComponent(summary.assetVersion)}`
: "";
return getBackendUrl(
`/plugin-assets/${encodeURIComponent(summary.id)}/${file}${version}`,
);
}
/** Absolute URL, so import() resolves it against the page, not this module. */
function absolute(url: string): string {
return new URL(url, window.location.href).href;
}
const defaultDeps: PluginLoaderDeps = {
fetchPlugins: getPlugins,
async importFrontend(summary) {
const workspace = workspaceFrontends[summary.id];
if (workspace) return (await workspace()) as FrontendModule;
return (await import(
/* @vite-ignore */ absolute(assetUrl(summary, "frontend.js"))
)) as FrontendModule;
},
async loadLocale(summary, file) {
const workspace = workspaceLocales[summary.id]?.[file];
if (workspace) return (await workspace()) as ResourceKey;
const hasFile = summary.locales?.includes(file);
if (!hasFile) return null;
const path = file === "en" ? "en.json" : `translated/${file}.json`;
const response = await fetch(
absolute(assetUrl(summary, `locales/${path}`)),
);
if (!response.ok) return null;
return (await response.json()) as ResourceKey;
},
injectCss(summary) {
if (!summary.css || typeof document === "undefined") return null;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = absolute(assetUrl(summary, "frontend.css"));
link.dataset.plugin = summary.id;
document.head.appendChild(link);
return link;
},
};
function currentDeps(): PluginLoaderDeps {
return deps ?? defaultDeps;
}
/** Test seam: swaps how plugins are fetched, imported and localized. */
export function configurePluginLoader(next: Partial<PluginLoaderDeps>): void {
deps = { ...defaultDeps, ...next };
}
function serverAllowsFrontend(summary: PluginSummary): boolean {
return (
summary.enabled &&
summary.state !== "failed" &&
summary.state !== "blocked" &&
!!summary.frontend
);
}
/**
* Enabled plugins in an order where each comes after its dependencies. A
* cycle or a missing hard dependency leaves the plugin out, marked blocked.
*/
export function orderForActivation(summaries: PluginSummary[]): {
order: PluginSummary[];
blocked: Map<string, string>;
} {
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
const order: PluginSummary[] = [];
const blocked = new Map<string, string>();
const state = new Map<string, "visiting" | "done">();
const visit = (summary: PluginSummary): boolean => {
const mark = state.get(summary.id);
if (mark === "done") return !blocked.has(summary.id);
if (mark === "visiting") {
blocked.set(summary.id, "dependency cycle");
return false;
}
state.set(summary.id, "visiting");
for (const dependency of Object.keys(summary.dependencies ?? {})) {
const target = byId.get(dependency);
if (!target || !serverAllowsFrontend(target) || !visit(target)) {
blocked.set(summary.id, `needs ${dependency}`);
}
}
for (const dependency of Object.keys(summary.optionalDependencies ?? {})) {
const target = byId.get(dependency);
if (target && serverAllowsFrontend(target)) visit(target);
}
state.set(summary.id, "done");
if (blocked.has(summary.id)) return false;
order.push(summary);
return true;
};
for (const summary of summaries) {
if (serverAllowsFrontend(summary)) visit(summary);
}
return { order, blocked };
}
async function deactivate(pluginId: string): Promise<void> {
const plugin = active.get(pluginId);
if (!plugin) return;
active.delete(pluginId);
try {
await plugin.module.deactivate?.();
} catch (error) {
console.error(`[plugins] ${pluginId}: deactivate threw`, error);
}
// Runs even when deactivate threw or does not exist.
plugin.handle.dispose();
plugin.cssLink?.remove();
setFrontendState(pluginId, "inactive");
}
async function activate(summary: PluginSummary): Promise<void> {
const loader = currentDeps();
setFrontendState(summary.id, "loading");
let cssLink: HTMLLinkElement | null = null;
let handle: PluginAppHandle | null = null;
try {
const module = await loader.importFrontend(summary);
if (typeof module?.activate !== "function") {
throw new Error("frontend entry does not export activate(app)");
}
cssLink = loader.injectCss(summary);
handle = createPluginApp(
summary.id,
manifestFor(summary),
summary.contributes,
{ guest: guestMode },
);
await module.activate(handle.app);
active.set(summary.id, {
id: summary.id,
assetVersion: summary.assetVersion ?? null,
handle,
module,
cssLink,
});
failedVersions.delete(summary.id);
setFrontendState(summary.id, "active");
} catch (error) {
// A failed activate is still cleaned up, and its deactivate is not
// called: it never finished starting.
handle?.dispose();
cssLink?.remove();
const message = error instanceof Error ? error.message : String(error);
console.error(`[plugins] ${summary.id} failed to start`, error);
failedVersions.set(summary.id, summary.assetVersion ?? null);
setFrontendState(summary.id, "failed", message);
}
}
/** The manifest subset the browser has. The full one stays on the server. */
function manifestFor(summary: PluginSummary): PluginManifest {
return {
id: summary.id,
name: summary.name,
version: summary.version,
description: "",
author: { name: "" },
license: "",
category: "",
engine: { termix: "", api: "1" },
capabilities: summary.capabilities ?? [],
dependencies: summary.dependencies,
optionalDependencies: summary.optionalDependencies,
contributes: summary.contributes as PluginManifest["contributes"],
icon: summary.icon,
};
}
async function loadNamespaces(summaries: PluginSummary[]): Promise<void> {
const ids = summaries.map((summary) => summary.id);
if (ids.length === 0) return;
// Reloading picks up a plugin that was upgraded while the app was open.
await i18n.reloadResources(undefined, ids).catch(() => {});
await i18n.loadNamespaces(ids).catch(() => {});
}
async function reconcile(summaries: PluginSummary[]): Promise<void> {
setPluginSummaries(summaries);
// The host editor's Plugins tab exists only while something fills it.
syncHostPluginsTab(summaries);
await loadNamespaces(summaries);
const { order, blocked } = orderForActivation(summaries);
const wanted = new Map(order.map((summary) => [summary.id, summary]));
// Dependents go first, so nothing outlives what it depends on.
for (const id of [...active.keys()].reverse()) {
const next = wanted.get(id);
const current = active.get(id)!;
if (!next || (next.assetVersion ?? null) !== current.assetVersion) {
await deactivate(id);
}
}
for (const [id, reason] of blocked) {
if (getPluginRecord(id)?.frontend !== "blocked") {
setFrontendState(id, "blocked", reason);
}
}
// A plugin that was switched off gets a fresh attempt when it comes back.
for (const id of [...failedVersions.keys()]) {
if (!wanted.has(id)) failedVersions.delete(id);
}
for (const summary of order) {
if (active.has(summary.id)) continue;
// Do not retry a bundle that already failed until it changes.
if (
failedVersions.has(summary.id) &&
failedVersions.get(summary.id) === (summary.assetVersion ?? null)
) {
setFrontendState(
summary.id,
"failed",
getPluginRecord(summary.id)?.error,
);
continue;
}
await activate(summary);
}
}
function enqueue(task: () => Promise<void>): Promise<void> {
queue = queue.then(task, task);
return queue;
}
/**
* Fetches plugin state and brings the loaded frontends in line with it. Safe
* to call as often as needed; calls run one after another.
*/
export function syncPlugins(summaries?: PluginSummary[]): Promise<void> {
return enqueue(async () => {
let list = summaries;
if (!list) {
try {
list = await currentDeps().fetchPlugins();
} catch (error) {
console.error("[plugins] could not load the plugin list", error);
// The shell must not wait on plugins forever.
if (!getPluginStoreState().loaded) setPluginSummaries([]);
markPluginsSettled();
return;
}
}
await reconcile(list);
markPluginsSettled();
});
}
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
let lastFocusSync = 0;
const FOCUS_SYNC_INTERVAL_MS = 30_000;
function onPluginsChanged() {
void syncPlugins();
}
function onFocus() {
const now = Date.now();
if (now - lastFocusSync < FOCUS_SYNC_INTERVAL_MS) return;
lastFocusSync = now;
void syncPlugins();
}
async function fetchGuestPlugins(): Promise<PluginSummary[]> {
const response = await fetch(getBackendUrl("/plugins/public"));
if (!response.ok) return [];
const body = (await response.json()) as unknown;
return Array.isArray(body) ? (body as PluginSummary[]) : [];
}
/**
* Starts the runtime. Idempotent. `guest` is for anonymous pages (shared
* session links): only plugins declaring contributes.guest load, from the
* public list, and nothing is refetched on focus.
*/
export function startPluginRuntime(
options: { guest?: boolean } = {},
): Promise<void> {
installPluginHostBridge();
if (options.guest) {
guestMode = true;
if (!deps) configurePluginLoader({ fetchPlugins: fetchGuestPlugins });
return syncPlugins();
}
setPluginLocaleResolver((namespace, _language, file) => {
const summary = getPluginRecord(namespace)?.summary;
if (!summary) return Promise.resolve(null);
return currentDeps().loadLocale(summary, file);
});
if (!started && typeof window !== "undefined") {
started = true;
lastFocusSync = Date.now();
window.addEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
window.addEventListener("focus", onFocus);
}
return syncPlugins();
}
/** Deactivates every plugin, on logout. */
export function stopPluginRuntime(): Promise<void> {
if (started && typeof window !== "undefined") {
started = false;
window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
window.removeEventListener("focus", onFocus);
}
return enqueue(async () => {
for (const id of [...active.keys()].reverse()) await deactivate(id);
});
}
export function isPluginFrontendActive(pluginId: string): boolean {
return active.has(pluginId);
}
/** Test seam. */
export async function resetPluginLoader(): Promise<void> {
await stopPluginRuntime();
deps = null;
queue = Promise.resolve();
failedVersions.clear();
}
+115
View File
@@ -0,0 +1,115 @@
import { useSyncExternalStore } from "react";
import type { PluginSummary } from "@/api/plugins-api";
/**
* What the browser knows about each plugin: the server's summary plus how
* far its frontend got.
*
* `frontend` is separate from the server state on purpose. A plugin can be
* enabled and healthy on the server while its bundle failed to import, and
* the shell has to show that as failed rather than as missing.
*/
export type FrontendState =
"none" | "loading" | "active" | "failed" | "blocked" | "inactive";
export interface PluginRecord {
summary: PluginSummary;
frontend: FrontendState;
error?: string;
}
interface StoreState {
/** False until the first GET /plugins answers. */
loaded: boolean;
/**
* True once every enabled frontend has been tried at least once. The shell
* restores saved tabs only after this, so plugin tabs come back as
* themselves rather than as placeholders.
*/
settled: boolean;
records: ReadonlyMap<string, PluginRecord>;
}
let state: StoreState = { loaded: false, settled: false, records: new Map() };
const listeners = new Set<() => void>();
function commit(next: StoreState): void {
state = next;
for (const listener of listeners) listener();
}
export function subscribePluginStore(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getPluginStoreState(): StoreState {
return state;
}
export function usePluginStore(): StoreState {
return useSyncExternalStore(
subscribePluginStore,
getPluginStoreState,
getPluginStoreState,
);
}
/** Replaces the summaries, keeping each plugin's frontend state. */
export function setPluginSummaries(summaries: PluginSummary[]): void {
const records = new Map<string, PluginRecord>();
for (const summary of summaries) {
const previous = state.records.get(summary.id);
records.set(summary.id, {
summary,
frontend: previous?.frontend ?? "none",
error: previous?.error,
});
}
commit({ ...state, loaded: true, records });
}
export function markPluginsSettled(): void {
if (!state.settled) commit({ ...state, settled: true });
}
export function setFrontendState(
pluginId: string,
frontend: FrontendState,
error?: string,
): void {
const record = state.records.get(pluginId);
if (!record) return;
if (record.frontend === frontend && record.error === error) return;
const records = new Map(state.records);
records.set(pluginId, { ...record, frontend, error });
commit({ ...state, records });
}
export function getPluginRecord(pluginId: string): PluginRecord | undefined {
return state.records.get(pluginId);
}
/** Resolves once plugins have settled; at once if they already have. */
export function settledPromise(): Promise<void> {
if (state.settled) return Promise.resolve();
return new Promise((resolve) => {
const unsubscribe = subscribePluginStore(() => {
if (!state.settled) return;
unsubscribe();
resolve();
});
});
}
/** Plugin ids known to the server, used to resolve permission namespaces. */
export function knownPluginIds(): string[] {
return [...state.records.keys()];
}
/** Test seam. */
export function resetPluginStore(): void {
commit({ loaded: false, settled: false, records: new Map() });
}
+112
View File
@@ -0,0 +1,112 @@
/* eslint-disable react-refresh/only-export-components */
import {
Component,
createContext,
Suspense,
useContext,
type ComponentType,
type ErrorInfo,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { getPluginRecord } from "./plugin-store";
const PluginScopeContext = createContext<string | null>(null);
/** The plugin whose component is rendering, or null in core UI. */
export function usePluginScope(): string | null {
return useContext(PluginScopeContext);
}
function CrashNotice({
pluginId,
onRetry,
}: {
pluginId: string;
onRetry: () => void;
}) {
const { t } = useTranslation();
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId;
return (
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<span>{t("plugins.runtime.crashed", { name })}</span>
<button
type="button"
className="text-xs underline underline-offset-2 hover:text-foreground"
onClick={onRetry}
>
{t("plugins.runtime.retry")}
</button>
</div>
);
}
interface BoundaryProps {
pluginId: string;
children: ReactNode;
}
/** A plugin component that throws takes down itself, never the shell. */
class PluginErrorBoundary extends Component<BoundaryProps, { error: boolean }> {
state = { error: false };
static getDerivedStateFromError() {
return { error: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(
`[plugins] ${this.props.pluginId} crashed while rendering`,
error,
info.componentStack,
);
}
render() {
if (this.state.error) {
return (
<CrashNotice
pluginId={this.props.pluginId}
onRetry={() => this.setState({ error: false })}
/>
);
}
return this.props.children;
}
}
export function PluginScope({
pluginId,
children,
}: {
pluginId: string;
children: ReactNode;
}) {
return (
<PluginScopeContext.Provider value={pluginId}>
<PluginErrorBoundary pluginId={pluginId}>
<Suspense fallback={null}>{children}</Suspense>
</PluginErrorBoundary>
</PluginScopeContext.Provider>
);
}
/**
* Wraps a plugin's component once, at registration, so every place the shell
* renders it gets the plugin id for the SDK hooks and an error boundary,
* without each call site having to remember.
*/
export function withPluginScope<P extends object>(
pluginId: string,
Inner: ComponentType<P>,
): ComponentType<P> {
function Scoped(props: P) {
return (
<PluginScope pluginId={pluginId}>
<Inner {...props} />
</PluginScope>
);
}
Scoped.displayName = `Plugin(${pluginId}:${Inner.displayName ?? Inner.name ?? "Component"})`;
return Scoped;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* @termix/plugin-sdk/ui: the shell's components, for plugins.
*
* Everything exported here is public API. A plugin builds its UI from these
* so it looks like the rest of the app and picks up theme changes, and it
* receives the shell's own instances through the import map, never a copy.
*
* The list is deliberate. It covers what the bundled plugins use today: the
* shadcn primitives, the composites for metrics, connection screens and
* settings rows, and the contexts a connection surface needs. Adding to it is
* a contract change; update ARCHITECTURE.md with it. Removing from it breaks
* plugins.
*/
// Primitives
export * from "@/components/alert";
export * from "@/components/alert-dialog";
export * from "@/components/badge";
export * from "@/components/button";
export * from "@/components/card";
export * from "@/components/checkbox";
export * from "@/components/dialog";
export * from "@/components/dropdown-menu";
export * from "@/components/input";
export * from "@/components/label";
export * from "@/components/password-input";
export * from "@/components/select";
export * from "@/components/select2";
export * from "@/components/separator";
export * from "@/components/switch";
export * from "@/components/textarea";
export * from "@/components/tooltip";
// Composites
export * from "@/components/section-card";
export * from "@/components/metric-card";
export * from "@/components/charts";
export {
CardGridCanvas,
ColumnCountStepper,
} from "@/components/card-grid/CardGridCanvas";
export type * from "@/components/card-grid/types";
export { ConnectionScreen } from "@/components/connection/ConnectionScreen";
export * from "@/components/connection/connection-status";
export { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
export {
FullScreenAppWrapper,
type FullScreenAppPhase,
} from "@/features/FullScreenAppWrapper";
// Connection surfaces
export {
ConnectionLogProvider,
useConnectionLog,
useOptionalConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext";
export { TOTPDialog, type MFAPromptMode } from "@/ssh/dialogs/TOTPDialog";
export { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog";
export { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog";
// Shell context
export { useTabs, useTabsSafe } from "@/shell/TabContext";
// Slots: a plugin can offer places for other plugins to fill.
export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
+126
View File
@@ -0,0 +1,126 @@
import { useSyncExternalStore } from "react";
import type { Host } from "@/types/ui-types";
import type { TabShellCallbacks } from "@/shell/tab-registry";
/**
* The live shell, as plugins see it.
*
* AppShell owns tabs and hosts in React state. It publishes callbacks and
* the host list here on every render, and plugin code (the app object, the
* SDK hooks, registered host actions) calls through this module, so nothing
* has to be threaded through props into a plugin.
*/
type Layout = { version: number; [key: string]: unknown };
export interface ShellLayoutProvider {
getLayout: () => Layout | null;
applyLayout: (
layout: Layout,
options?: { name?: string },
) => Promise<{ skipped: string[] }>;
}
let callbacks: TabShellCallbacks | null = null;
let layoutProvider: ShellLayoutProvider | null = null;
let hosts: Host[] = [];
let hostsLoaded = false;
let ready = false;
const hostListeners = new Set<() => void>();
const changeListeners = new Set<() => void>();
const readyListeners = new Set<() => void>();
export function setShellCallbacks(next: TabShellCallbacks | null): void {
callbacks = next;
}
export function setShellLayoutProvider(next: ShellLayoutProvider | null): void {
layoutProvider = next;
}
export function setShellHosts(next: Host[], loaded = true): void {
if (next === hosts && loaded === hostsLoaded) return;
hosts = next;
hostsLoaded = loaded;
hostSnapshot = { hosts, loaded: hostsLoaded };
for (const listener of hostListeners) listener();
}
let hostSnapshot = { hosts, loaded: hostsLoaded };
export function useShellHosts(): { hosts: Host[]; loaded: boolean } {
return useSyncExternalStore(
(listener) => {
hostListeners.add(listener);
return () => hostListeners.delete(listener);
},
() => hostSnapshot,
() => hostSnapshot,
);
}
export function getShellHosts(): Host[] {
return hosts;
}
export function notifyTabsChanged(): void {
for (const listener of changeListeners) listener();
}
/** Called once the shell has restored its tabs. Late listeners fire at once. */
export function notifyShellReady(): void {
if (ready) return;
ready = true;
for (const listener of readyListeners) listener();
}
function subscribe(set: Set<() => void>, listener: () => void): () => void {
set.add(listener);
return () => {
set.delete(listener);
};
}
const noop = () => {};
/** Forwards to the mounted shell; a no-op before it mounts or after logout. */
export const shell: TabShellCallbacks = {
openTab: (...args) => callbacks?.openTab(...args),
openSingletonTab: (...args) => callbacks?.openSingletonTab(...args),
closeTab: (...args) => callbacks?.closeTab(...args),
renameTab: (...args) => callbacks?.renameTab(...args),
openFileInEditor: (...args) => callbacks?.openFileInEditor(...args),
openFileManager: (...args) => callbacks?.openFileManager(...args),
openTerminalTab: (...args) => callbacks?.openTerminalTab(...args),
openRailView: (...args) => callbacks?.openRailView(...args),
closeRailView: (...args) => callbacks?.closeRailView(...args),
saveQuickConnect: (...args) =>
callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(),
};
export const tabsApi = {
openTab: shell.openTab,
openSingletonTab: shell.openSingletonTab,
closeTab: shell.closeTab,
getLayout: () => layoutProvider?.getLayout() ?? null,
applyLayout: (layout: Layout, options?: { name?: string }) =>
layoutProvider
? layoutProvider.applyLayout(layout, options)
: Promise.resolve({ skipped: [] as string[] }),
onChange: (listener: () => void) => subscribe(changeListeners, listener),
onReady: (listener: () => void) => {
if (ready) {
listener();
return noop;
}
return subscribe(readyListeners, listener);
},
};
/** Test seam, and on logout. */
export function resetShellBridge(): void {
callbacks = null;
layoutProvider = null;
ready = false;
setShellHosts([], false);
}
+266
View File
@@ -0,0 +1,266 @@
/**
* The Termix side of @termix/plugin-sdk/testing's renderWithApp.
*
* Activates a plugin against the real registries, the real app object and
* the real SDK bridge, then renders whatever it registered with Testing
* Library. Shell calls are recorded rather than performed, since there is no
* AppShell in a unit test.
*/
import type { ReactElement } from "react";
import { render } from "@testing-library/react";
import type { PluginManifest } from "@termix/plugin-sdk/frontend";
import type {
FrontendPluginModule,
RenderWithAppOptions,
RenderedPluginApp,
ShellCall,
} from "@termix/plugin-sdk/testing";
import i18n from "@/i18n/i18n";
import type { PluginSummary } from "@/api/plugins-api";
import { ThemeProvider } from "@/components/theme-provider";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
import {
getTabType,
listTabTypes,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { getPanel, listPanels } from "@/shell/panel-registry";
import { listRegisteredRailItems } from "@/sidebar/rail-items";
import { listHostActions } from "@/sidebar/host-contributions";
import {
getHostEditorSection,
hostEditorSectionList,
} from "@/sidebar/HostManagerTabs";
import {
getRegisteredDashboardCard,
registeredDashboardCardList,
} from "@/dashboard/dashboard-cards-registry";
import {
getSettingsComponent,
listSettingsComponents,
} from "@/settings/settings-components";
import { getSlotContributions, listActions } from "@/shell/action-registry";
import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
import type { Host, Tab } from "@/types/ui-types";
import { createPluginApp } from "./app";
import { installPluginHostBridge } from "./bridge";
import { setPluginSummaries, setFrontendState } from "./plugin-store";
import { setShellCallbacks, setShellHosts } from "./shell-bridge";
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
const record =
(method: string) =>
(...args: unknown[]) => {
calls.push({ method, args });
};
return {
openTab: record("openTab"),
openSingletonTab: record("openSingletonTab"),
closeTab: record("closeTab"),
renameTab: record("renameTab"),
openFileInEditor: record("openFileInEditor"),
openFileManager: record("openFileManager"),
openTerminalTab: record("openTerminalTab"),
openRailView: record("openRailView"),
closeRailView: record("closeRailView"),
saveQuickConnect: async (...args) => {
calls.push({ method: "saveQuickConnect", args });
},
};
}
function wrap(element: ReactElement): HTMLElement {
return render(
<ThemeProvider defaultTheme="dark" storageKey="termix-test-theme">
{element}
</ThemeProvider>,
).container;
}
function missing(kind: string, id: string): never {
throw new Error(`renderWithApp: nothing registered ${kind} "${id}"`);
}
export async function renderPlugin(
plugin: FrontendPluginModule,
options: RenderWithAppOptions = {},
): Promise<RenderedPluginApp> {
const pluginId = options.pluginId ?? options.manifest?.id ?? "test-plugin";
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...options.manifest,
} as PluginManifest;
installPluginHostBridge();
setPermissionsForTesting(options.permissions ?? [], options.isAdmin);
setShellHosts((options.hosts ?? []) as unknown as Host[]);
const shellCalls: ShellCall[] = [];
const shell = recordingShell(shellCalls);
setShellCallbacks(shell);
const summary: PluginSummary = {
id: pluginId,
name: manifest.name,
version: manifest.version,
enabled: true,
state: "active",
contributes: (manifest.contributes ?? null) as PluginSummary["contributes"],
frontend: true,
};
setPluginSummaries([summary]);
if (options.locales) {
i18n.addResourceBundle(
"en",
pluginId,
options.locales as Record<string, unknown>,
true,
true,
);
}
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
guest: options.guest,
});
await plugin.activate(handle.app);
setFrontendState(pluginId, "active");
const mine = <T extends { pluginId?: string }>(items: T[]) =>
items.filter((item) => item.pluginId === pluginId);
const tabRecord = (type: string, host?: Host): Tab => ({
id: `${type}-test`,
instanceId: `${type}-test`,
type,
label: type,
host,
openedAt: 0,
});
return {
app: handle.app,
shellCalls,
registered: {
railItems: () =>
mine(listRegisteredRailItems()).map((item) => ({
id: item.id,
hidden: item.hidden,
})),
tabs: () => mine(listTabTypes()).map((def) => def.id),
panels: () => mine(listPanels()).map((panel) => panel.id),
hostActions: () =>
mine(listHostActions()).map((action) => ({
id: action.id,
tabType: action.tabType,
})),
hostEditorSections: () =>
mine(hostEditorSectionList()).map((section) => section.id),
dashboardCards: () =>
mine(registeredDashboardCardList()).map((card) => card.id),
settingsComponents: () =>
listSettingsComponents()
.filter((key) => key.startsWith(`${pluginId}:`))
.map((key) => key.slice(pluginId.length + 1)),
slot: (slotId) =>
mine(getSlotContributions(slotId)).map(
(contribution) => contribution.actionId,
),
actions: () => mine(listActions()).map((action) => action.id),
},
renderTab(type, props = {}) {
const def = getTabType(type) ?? missing("a tab", type);
const Component = def.component;
const host = props.host as Host | undefined;
return wrap(
<Component
tab={tabRecord(type, host)}
host={host}
label={type}
isVisible
isFocusedPane
handleRef={null}
shell={shell}
{...props}
/>,
);
},
renderPanel(id, props = {}) {
const panel = getPanel(id) ?? missing("a panel", id);
const Component = panel.component;
return wrap(
<Component
active
shell={shell}
setEditing={() => {}}
placement="left"
{...props}
/>,
);
},
renderDashboardCard(id) {
const card = getRegisteredDashboardCard(id) ?? missing("a card", id);
const Component = card.component;
return wrap(<Component isVisible shell={shell} />);
},
renderHostEditorSection(id, props = {}) {
const section =
getHostEditorSection(id) ?? missing("a host editor section", id);
const Component = section.component;
return wrap(
<Component
form={{}}
setField={() => {}}
updateForm={() => {}}
protocols={{}}
{...props}
/>,
);
},
renderSettingsComponent(componentId, props = {}) {
const Component =
getSettingsComponent(pluginId, componentId) ??
missing("a settings component", componentId);
return wrap(
<Component
pluginId={pluginId}
values={{}}
setValue={() => {}}
running
{...props}
/>,
);
},
renderSlot(slotId, props = {}) {
return wrap(
<>
<ActionSlot slotId={slotId} />
<ComponentSlot slotId={slotId} props={props} />
</>,
);
},
async deactivate() {
try {
await plugin.deactivate?.();
} finally {
handle.dispose();
setFrontendState(pluginId, "inactive");
}
},
};
}
+89
View File
@@ -0,0 +1,89 @@
import { getPluginStoreState, type PluginRecord } from "./plugin-store";
/**
* Who owns a tab type, rail view or dashboard card.
*
* Read from the manifests in GET /plugins rather than from what registered,
* because a disabled plugin never runs its frontend and so never registers
* anything, yet a saved tab or card of its still has to say whose it is.
*/
export type ViewKind = "tab" | "panel" | "card";
export type ViewStatus =
/** Registered by a running plugin. */
| "ready"
/** Owner is enabled but its frontend has not finished loading. */
| "loading"
/** Owner is installed but switched off. */
| "disabled"
/** Owner's frontend or backend failed, or a hard dependency is missing. */
| "failed"
/** Nobody we know of declares this view. */
| "missing";
function declares(record: PluginRecord, kind: ViewKind, id: string): boolean {
const contributes = record.summary.contributes;
if (!contributes) return false;
if (kind === "tab") return !!contributes.tabs?.some((tab) => tab.id === id);
if (kind === "panel") {
return (
!!contributes.panels?.some((panel) => panel.id === id) ||
!!contributes.tabs?.some((tab) => tab.id === id)
);
}
return !!contributes.dashboardCards?.some((card) => card.id === id);
}
export function viewOwner(
kind: ViewKind,
id: string,
): PluginRecord | undefined {
for (const record of getPluginStoreState().records.values()) {
if (declares(record, kind, id)) return record;
}
return undefined;
}
/** Whether a manifest declares the id, i.e. the plugin may register it. */
export function manifestDeclares(
contributes: PluginRecord["summary"]["contributes"],
kind: ViewKind,
id: string,
): boolean {
if (!contributes) return false;
return declares(
{ summary: { contributes } as PluginRecord["summary"], frontend: "none" },
kind,
id,
);
}
/**
* Status of a view that is NOT currently registered. Callers check their
* registry first; this explains why it is absent.
*/
export function unregisteredViewStatus(
kind: ViewKind,
id: string,
): { status: Exclude<ViewStatus, "ready">; owner?: PluginRecord } {
const owner = viewOwner(kind, id);
if (!owner) {
return { status: getPluginStoreState().loaded ? "missing" : "loading" };
}
const serverState = owner.summary.state;
if (!owner.summary.enabled) return { status: "disabled", owner };
if (
owner.frontend === "failed" ||
owner.frontend === "blocked" ||
serverState === "failed" ||
serverState === "blocked"
) {
return { status: "failed", owner };
}
// Running, yet it never registered this view: it declared more than it
// ships. Nothing will arrive, so do not spin.
if (owner.frontend === "active" || !owner.summary.frontend) {
return { status: "missing", owner };
}
return { status: "loading", owner };
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Plugin sources in this monorepo, for the Vite dev server.
*
* Empty everywhere except `vite dev`, where the termix-plugin-host Vite plugin
* replaces this module with import.meta.glob calls over plugins/<id>/, so
* plugin frontends load through Vite with HMR instead of from their built
* bundles. A production build keeps it empty: plugins are separate bundles
* served from /plugin-assets, never part of the shell's own build.
*/
type Loader = () => Promise<unknown>;
/** plugin id -> its src/frontend/index.tsx */
export const workspaceFrontends: Record<string, Loader> = {};
/** plugin id -> locale file ("en", "de_DE") -> JSON */
export const workspaceLocales: Record<string, Record<string, Loader>> = {};