mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>> = {};
|
||||
Reference in new issue
Block a user