mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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,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");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user