mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -1,29 +1,478 @@
|
||||
/**
|
||||
* The frontend contract: what a plugin's frontend entry receives.
|
||||
* The frontend contract: what a plugin's frontend entry receives, and the
|
||||
* hooks its components use.
|
||||
*
|
||||
* Stubs only. A7 builds the real loader and fills these in, at which point a
|
||||
* plugin registers every surface it contributes through this object and the
|
||||
* shell stops carrying plugin ids of its own.
|
||||
* A plugin frontend is `src/frontend/index.tsx` exporting `activate(app)` and
|
||||
* optionally `deactivate()`. Everything it contributes goes through `app`, and
|
||||
* everything registered there is removed when the plugin is disabled, without
|
||||
* a page reload.
|
||||
*
|
||||
* This module has no runtime dependencies. The hooks delegate to a host that
|
||||
* core installs at startup (`__setPluginHost`), because React, i18next and the
|
||||
* shell's state all live in core and must be one instance across the shell
|
||||
* and every plugin bundle.
|
||||
*/
|
||||
|
||||
import type { ComponentType, ReactNode, Ref } from "react";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
export type Disposer = () => void;
|
||||
|
||||
/** A Lucide icon or anything with the same props. */
|
||||
export type IconComponent = ComponentType<{
|
||||
className?: string;
|
||||
size?: number | string;
|
||||
strokeWidth?: number | string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A host as the shell holds it. Only the identifying fields are typed; the
|
||||
* rest of the record is passed through as the shell has it, so a plugin
|
||||
* that needs a feature column reads it by name.
|
||||
*/
|
||||
export interface PluginHostRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** An open tab as the shell holds it. `data` is the plugin's own payload. */
|
||||
export interface PluginTabRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
host?: PluginHostRecord;
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OpenTabOptions {
|
||||
label?: string;
|
||||
/** Open a second tab even when one for this host and type exists. */
|
||||
forceNewTab?: boolean;
|
||||
/** Plugin payload, stored on the tab and restored with it. */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A saved arrangement of tabs, used by workspaces. Opaque to plugins. */
|
||||
export interface ShellLayout {
|
||||
version: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** What a plugin may ask of the shell. */
|
||||
export interface ShellApi {
|
||||
openTab: (
|
||||
host: PluginHostRecord | null,
|
||||
type: string,
|
||||
options?: OpenTabOptions,
|
||||
) => void;
|
||||
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
renameTab: (tabId: string, label: string) => void;
|
||||
openFileInEditor: (host: PluginHostRecord, path: string) => void;
|
||||
openFileManager: (host: PluginHostRecord, path?: string) => void;
|
||||
openTerminalTab: (host: PluginHostRecord, path?: string) => void;
|
||||
/** Opens a rail view in the left sidebar. */
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown. */
|
||||
closeRailView: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface TabsApi extends Pick<
|
||||
ShellApi,
|
||||
"openTab" | "openSingletonTab" | "closeTab"
|
||||
> {
|
||||
/** The current tabs and split layout, or null before the shell mounts. */
|
||||
getLayout: () => ShellLayout | null;
|
||||
/**
|
||||
* Replaces the open tabs with a saved layout. `name` labels a restored
|
||||
* split. Resolves with the tabs that could not be reopened, e.g. because
|
||||
* their host was deleted.
|
||||
*/
|
||||
applyLayout: (
|
||||
layout: ShellLayout,
|
||||
options?: { name?: string },
|
||||
) => Promise<{ skipped: string[] }>;
|
||||
/** Called whenever tabs open, close or move. */
|
||||
onChange: (listener: () => void) => Disposer;
|
||||
/** Called once the shell has restored its tabs after login. */
|
||||
onReady: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RailItemContribution {
|
||||
/** Must be declared in manifest contributes.tabs or contributes.panels. */
|
||||
id: string;
|
||||
icon: IconComponent;
|
||||
titleKey: string;
|
||||
/** "panel" opens the left sidebar, "tab" opens a tab. Default "panel". */
|
||||
kind?: "panel" | "tab";
|
||||
/** Users may hide it from Appearance > Sidebar > Navigation. Default true. */
|
||||
hideable?: boolean;
|
||||
/** Can also open as a full-width tab. */
|
||||
promotable?: boolean;
|
||||
/** Can open in the right dock. */
|
||||
rightDockable?: boolean;
|
||||
/** Shown on the mobile bar's primary row. */
|
||||
mobilePrimary?: boolean;
|
||||
/** Desktop app only. */
|
||||
electronOnly?: boolean;
|
||||
separatorAfter?: boolean;
|
||||
/** Hidden without being unregistered, e.g. while a feature is switched off. */
|
||||
hidden?: boolean;
|
||||
/** Place it after this rail id rather than at the end. */
|
||||
after?: string;
|
||||
/** Lower sorts first among plugin items. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface PanelProps {
|
||||
/** Whether the panel is the one currently shown. */
|
||||
active: boolean;
|
||||
shell: ShellApi;
|
||||
/** Tells the shell the panel is editing, which widens the sidebar. */
|
||||
setEditing: (editing: boolean) => void;
|
||||
/** Type of the focused tab, if any. */
|
||||
activeTabType?: string;
|
||||
/** Where the panel is rendered. */
|
||||
placement: "left" | "right" | "tab";
|
||||
}
|
||||
|
||||
export interface PanelOptions {
|
||||
/** Keep the panel mounted after the user switches away. */
|
||||
keepMounted?: boolean;
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
tab: PluginTabRecord;
|
||||
host?: PluginHostRecord;
|
||||
/** The host in the shape the connection APIs take. */
|
||||
sshHost?: Record<string, unknown>;
|
||||
label: string;
|
||||
isVisible: boolean;
|
||||
isFocusedPane: boolean;
|
||||
/** Forward to a component exposing `refresh()` or `disconnect()`. */
|
||||
handleRef: Ref<unknown>;
|
||||
shell: ShellApi;
|
||||
}
|
||||
|
||||
export interface StandaloneViewProps {
|
||||
hostId?: string;
|
||||
view: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface TabOptions {
|
||||
icon?: IconComponent;
|
||||
/** Label for singleton tabs and anywhere the type is named. */
|
||||
titleKey?: string;
|
||||
/** Needs a host; renders `noHostMessageKey` without one. */
|
||||
requiresHost?: boolean;
|
||||
noHostMessageKey?: string;
|
||||
/** Reopened after login. */
|
||||
persistent?: boolean;
|
||||
/** One tab of this type at a time, keyed by type. */
|
||||
singleton?: boolean;
|
||||
/** A live session: asks before closing and can be refreshed. */
|
||||
session?: boolean;
|
||||
/** Restorable without a host. */
|
||||
hostless?: boolean;
|
||||
/** Decides whether a saved tab for this host may be restored. */
|
||||
restore?: (host: PluginHostRecord) => boolean;
|
||||
/** Recent-activity types that open this tab. */
|
||||
activityTypes?: string[];
|
||||
/** Rendered for `?view=<type>` full-screen links. */
|
||||
standalone?: ComponentType<StandaloneViewProps>;
|
||||
/** Extra `?view=` names that also open `standalone`. */
|
||||
standaloneViews?: string[];
|
||||
/** Wrap in the readable-width frame used by panels opened as tabs. */
|
||||
panelFrame?: boolean;
|
||||
/** False keeps the tab out of saved layouts and workspaces. Default true. */
|
||||
inLayouts?: boolean;
|
||||
/** Warms the tab's code before it is opened. */
|
||||
preload?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface HostEditorSectionProps {
|
||||
// The editor form is a plain object; a section reads the fields it owns.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: any;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
/** Sets several fields at once, computed from the latest form. */
|
||||
updateForm: (
|
||||
patch: (form: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
host?: PluginHostRecord;
|
||||
credentials?: unknown[];
|
||||
snippets?: unknown[];
|
||||
/** Which connection protocols are switched on for this host. */
|
||||
protocols: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface HostEditorSectionContribution {
|
||||
id: string;
|
||||
/** "top" sits in the main tab strip, "ssh" under the SSH group. */
|
||||
group: "top" | "ssh";
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
/**
|
||||
* Position among the group's tabs, core ones included. Top: General 0,
|
||||
* SSH 10. SSH group: General 0, Terminal 10, Tunnels 20, Files 60.
|
||||
*/
|
||||
order?: number;
|
||||
/** Whether to offer the tab, from the host's enabled protocols. */
|
||||
visible?: (protocols: Record<string, boolean>) => boolean;
|
||||
component: ComponentType<HostEditorSectionProps>;
|
||||
}
|
||||
|
||||
export interface HostActionContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon: IconComponent;
|
||||
/**
|
||||
* "connect" is a way to open a session (terminal, RDP) and is offered as a
|
||||
* host's default action by priority. "open" opens a tool for the host.
|
||||
*/
|
||||
kind: "connect" | "open";
|
||||
/** Higher wins when choosing a host's default connect action. */
|
||||
priority?: number;
|
||||
/** Opened with shell.openTab when `run` is absent. */
|
||||
tabType?: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
run?: (host: PluginHostRecord, shell: ShellApi) => void;
|
||||
/** Shows a quick button on the host row. Default true. */
|
||||
tray?: boolean;
|
||||
/** `?view=` name for "Copy link". */
|
||||
copyUrlView?: string;
|
||||
/** Where a host overview (the dashboard's host status list) sends a click. */
|
||||
overview?: boolean;
|
||||
/**
|
||||
* Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect
|
||||
* actions default to 100 and sit after a separator.
|
||||
*/
|
||||
order?: number;
|
||||
/** A label worked out per host, e.g. a single endpoint's name. */
|
||||
label?: (host: PluginHostRecord) => string | undefined;
|
||||
/** Several targets: two or more become a picker, one runs directly. */
|
||||
items?: (host: PluginHostRecord) => {
|
||||
id: string;
|
||||
label: string;
|
||||
run: (host: PluginHostRecord, shell: ShellApi) => void;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface HostBadgeContribution {
|
||||
id: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
component: ComponentType<{ host: PluginHostRecord }>;
|
||||
}
|
||||
|
||||
export interface HostContextMenuItemContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
run: (host: PluginHostRecord, shell: ShellApi) => void;
|
||||
}
|
||||
|
||||
export interface PaletteEntryContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
keywords?: string[];
|
||||
/** "global" entries stand alone, "host" entries appear under a host. */
|
||||
scope: "global" | "host";
|
||||
when?: (host?: PluginHostRecord) => boolean;
|
||||
run: (shell: ShellApi, host?: PluginHostRecord) => void;
|
||||
}
|
||||
|
||||
export interface DashboardCardProps {
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
}
|
||||
|
||||
export interface DashboardCardContribution {
|
||||
/** Must be declared in manifest contributes.dashboardCards. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
defaultHeight?: number;
|
||||
component: ComponentType<DashboardCardProps>;
|
||||
}
|
||||
|
||||
export interface HomepageWidgetContribution<C = Record<string, unknown>> {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "links" | "info" | "system" | "monitoring";
|
||||
icon: ReactNode;
|
||||
defaultConfig: C;
|
||||
defaultSize: { w: number; h: number };
|
||||
minSize: { w: number; h: number };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editFormComponent?: ComponentType<any>;
|
||||
}
|
||||
|
||||
export interface SettingsComponentProps {
|
||||
pluginId: string;
|
||||
values: Record<string, unknown>;
|
||||
setValue: (key: string, value: unknown) => void;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export type ActionHandler = (...args: never[]) => unknown;
|
||||
|
||||
export interface ActionSlotDefinition {
|
||||
id: string;
|
||||
/** Contribution kinds the slot renders: "button", "component". */
|
||||
accepts: string[];
|
||||
}
|
||||
|
||||
export interface SlotContribution {
|
||||
actionId: string;
|
||||
titleKey: string;
|
||||
/** Secondary text, for slots that show one (onboarding cards). */
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
kind?: "button" | "component" | string;
|
||||
/** For kind "component". Receives the slot owner's props. */
|
||||
component?: ComponentType<Record<string, unknown>>;
|
||||
/** Extra condition on top of the action's permission. */
|
||||
when?: (context: Record<string, unknown>) => boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface SshAuthEditorProps {
|
||||
form: Record<string, unknown>;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export interface SshAuthEditorContribution {
|
||||
/** The value stored in the host's authType. */
|
||||
authType: string;
|
||||
titleKey: string;
|
||||
hintKey?: string;
|
||||
component?: ComponentType<SshAuthEditorProps>;
|
||||
}
|
||||
|
||||
/** A7 declares these; A8 wires them into the login screen. */
|
||||
export interface LoginMethodContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
component: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface SecondFactorContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
component: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** The subset of axios a plugin uses, rooted at /plugin-api/<id>/. */
|
||||
export interface PluginApiClient {
|
||||
get<T = unknown>(url: string, config?: unknown): Promise<{ data: T }>;
|
||||
delete<T = unknown>(url: string, config?: unknown): Promise<{ data: T }>;
|
||||
post<T = unknown>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: unknown,
|
||||
): Promise<{ data: T }>;
|
||||
put<T = unknown>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: unknown,
|
||||
): Promise<{ data: T }>;
|
||||
patch<T = unknown>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: unknown,
|
||||
): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
export interface PluginWsTarget {
|
||||
url: string;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export interface TermixAppInfo {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration surface for a plugin frontend.
|
||||
*
|
||||
* A7 adds: rail items, panels, tabs, host editor sections, host actions and
|
||||
* badges, context menu items, palette entries, dashboard cards, homepage
|
||||
* widgets, settings components, action slots, login/2FA UI and SSH auth
|
||||
* editors. Everything registered here is removed when the plugin is disabled.
|
||||
* Registration surface for a plugin frontend. Every `register*` returns a
|
||||
* disposer, and anything not disposed by hand is disposed when the plugin is
|
||||
* disabled.
|
||||
*/
|
||||
export interface TermixApp extends TermixAppInfo {
|
||||
/**
|
||||
* True on anonymous guest pages (a shared-session link). Only plugins with
|
||||
* contributes.guest run there, and they should register just what a guest
|
||||
* sees: there is no user, so API calls needing a login will fail.
|
||||
*/
|
||||
readonly guest: boolean;
|
||||
registerRailItem: (item: RailItemContribution) => Disposer;
|
||||
registerPanel: (
|
||||
id: string,
|
||||
component: ComponentType<PanelProps>,
|
||||
options?: PanelOptions,
|
||||
) => Disposer;
|
||||
registerTab: (
|
||||
type: string,
|
||||
component: ComponentType<TabProps>,
|
||||
options?: TabOptions,
|
||||
) => Disposer;
|
||||
registerHostEditorSection: (
|
||||
section: HostEditorSectionContribution,
|
||||
) => Disposer;
|
||||
registerHostAction: (action: HostActionContribution) => Disposer;
|
||||
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
|
||||
registerHostContextMenuItem: (
|
||||
item: HostContextMenuItemContribution,
|
||||
) => Disposer;
|
||||
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
||||
registerDashboardCard: (card: DashboardCardContribution) => Disposer;
|
||||
registerHomepageWidget: (widget: HomepageWidgetContribution) => Disposer;
|
||||
registerSettingsComponent: (
|
||||
componentId: string,
|
||||
component: ComponentType<SettingsComponentProps>,
|
||||
) => Disposer;
|
||||
registerAction: (
|
||||
id: string,
|
||||
handler: ActionHandler,
|
||||
options?: { permission?: string },
|
||||
) => Disposer;
|
||||
declareActionSlot: (slot: ActionSlotDefinition) => Disposer;
|
||||
registerSlotContribution: (
|
||||
slotId: string,
|
||||
contribution: SlotContribution,
|
||||
) => Disposer;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||
|
||||
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */
|
||||
api: PluginApiClient;
|
||||
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
|
||||
wsUrl: (
|
||||
path: string,
|
||||
options?: { origin?: unknown },
|
||||
) => Promise<PluginWsTarget | null>;
|
||||
tabs: TabsApi;
|
||||
/** Registered cleanup, run when the plugin is disabled. */
|
||||
onDispose: (dispose: () => void) => void;
|
||||
onDispose: (dispose: Disposer) => void;
|
||||
}
|
||||
|
||||
export type FrontendActivate = (app: TermixApp) => void | Promise<void>;
|
||||
@@ -37,4 +486,158 @@ export function definePluginFrontend(plugin: FrontendModule): FrontendModule {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranslateFn = (
|
||||
key: string,
|
||||
options?: Record<string, unknown> | string,
|
||||
) => string;
|
||||
|
||||
export interface CurrentUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
}
|
||||
|
||||
export type SettingsScope = "admin" | "user" | "host";
|
||||
|
||||
export interface SettingsState {
|
||||
values: Record<string, unknown>;
|
||||
loaded: boolean;
|
||||
save: (values: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What core implements behind the hooks. Internal: a plugin never calls this.
|
||||
*/
|
||||
export interface PluginHostBridge {
|
||||
usePluginId: () => string;
|
||||
useTranslation: (pluginId: string) => {
|
||||
t: TranslateFn;
|
||||
language: string;
|
||||
};
|
||||
usePermission: (pluginId: string, permission: string) => boolean;
|
||||
useSettings: (
|
||||
pluginId: string,
|
||||
scope: SettingsScope,
|
||||
hostId?: number | string,
|
||||
) => SettingsState;
|
||||
useHost: (hostId: string | number | undefined) => PluginHostRecord | null;
|
||||
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||
useCurrentUser: () => CurrentUser | null;
|
||||
useTheme: () => { theme: "light" | "dark" };
|
||||
toast: ToastApi;
|
||||
getApi: (pluginId: string) => PluginApiClient;
|
||||
useTabs: () => TabsApi;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
let host: PluginHostBridge | null = null;
|
||||
|
||||
/** Called once by core. Not part of the plugin API. */
|
||||
export function __setPluginHost(bridge: PluginHostBridge | null): void {
|
||||
host = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set by the shared test setup, so a plugin component rendered on its own in a
|
||||
* unit test still has hooks. A global rather than an import, because the test
|
||||
* setup and the component can load different copies of this module.
|
||||
*/
|
||||
const TEST_HOST_KEY = "__termixTestPluginHost";
|
||||
|
||||
function requireHost(): PluginHostBridge {
|
||||
const fallback = (globalThis as Record<string, unknown>)[TEST_HOST_KEY] as
|
||||
PluginHostBridge | undefined;
|
||||
if (!host && fallback) return fallback;
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
"@termix/plugin-sdk/frontend: no plugin host. Hooks only work inside Termix or renderWithApp().",
|
||||
);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/** The id of the plugin that registered the component being rendered. */
|
||||
export function usePluginId(): string {
|
||||
return requireHost().usePluginId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation bound to this plugin's namespace. Keys the plugin does not
|
||||
* define fall back to core's shared strings (common.*, hosts.*).
|
||||
*/
|
||||
export function useTranslation(): { t: TranslateFn; language: string } {
|
||||
const bridge = requireHost();
|
||||
return bridge.useTranslation(bridge.usePluginId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user holds a permission. A short name resolves to this
|
||||
* plugin's own namespace, a dotted id is taken as given. UI only: the backend
|
||||
* route checks again.
|
||||
*/
|
||||
export function usePermission(permission: string): boolean {
|
||||
const bridge = requireHost();
|
||||
return bridge.usePermission(bridge.usePluginId(), permission);
|
||||
}
|
||||
|
||||
export function useSettings(
|
||||
scope: SettingsScope,
|
||||
hostId?: number | string,
|
||||
): SettingsState {
|
||||
const bridge = requireHost();
|
||||
return bridge.useSettings(bridge.usePluginId(), scope, hostId);
|
||||
}
|
||||
|
||||
export function useHost(
|
||||
hostId: string | number | undefined,
|
||||
): PluginHostRecord | null {
|
||||
return requireHost().useHost(hostId);
|
||||
}
|
||||
|
||||
export function useHosts(): { hosts: PluginHostRecord[]; loaded: boolean } {
|
||||
return requireHost().useHosts();
|
||||
}
|
||||
|
||||
export function useCurrentUser(): CurrentUser | null {
|
||||
return requireHost().useCurrentUser();
|
||||
}
|
||||
|
||||
export function useTheme(): { theme: "light" | "dark" } {
|
||||
return requireHost().useTheme();
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return requireHost().toast;
|
||||
}
|
||||
|
||||
export function usePluginApi(): PluginApiClient {
|
||||
const bridge = requireHost();
|
||||
return bridge.getApi(bridge.usePluginId());
|
||||
}
|
||||
|
||||
export function useTabs(): TabsApi {
|
||||
return requireHost().useTabs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action another plugin registered, from anywhere in a plugin's code.
|
||||
* Resolves undefined when nobody registered it, so an optional dependency
|
||||
* that is switched off reads as "nothing there".
|
||||
*/
|
||||
export function invokeAction(id: string, ...args: unknown[]): Promise<unknown> {
|
||||
return requireHost().invokeAction(id, ...args);
|
||||
}
|
||||
|
||||
export type { PluginManifest } from "./manifest.js";
|
||||
export type { ReactNode };
|
||||
@@ -31,7 +31,7 @@ export const PLUGIN_CATEGORIES = [
|
||||
|
||||
export const PLUGIN_PLATFORMS = ["linux", "win32", "darwin"] as const;
|
||||
|
||||
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
|
||||
export const ACTION_CONTRIBUTION_KINDS = ["button", "component"] as const;
|
||||
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
|
||||
|
||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const;
|
||||
@@ -97,6 +97,17 @@ export interface PluginTabContribution {
|
||||
openFrom: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sidebar panel or dashboard card the plugin's frontend registers. Declared
|
||||
* here so core knows who owns a saved view even while the plugin is off and
|
||||
* its code never loaded.
|
||||
*/
|
||||
export interface PluginViewContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One role permission a plugin contributes.
|
||||
*
|
||||
@@ -224,6 +235,13 @@ export interface PluginSettingsContribution {
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
dashboardCards?: PluginViewContribution[];
|
||||
/**
|
||||
* The frontend also runs on anonymous guest pages (shared-session and
|
||||
* collab links), where it is activated with app.guest set.
|
||||
*/
|
||||
guest?: boolean;
|
||||
actions?: PluginActionContribution[];
|
||||
actionSlots?: PluginActionSlot[];
|
||||
permissions?: PluginPermissionContribution[];
|
||||
@@ -302,6 +320,9 @@ const ALLOWED_TOP_LEVEL = new Set([
|
||||
|
||||
const ALLOWED_CONTRIBUTES = new Set([
|
||||
"tabs",
|
||||
"panels",
|
||||
"dashboardCards",
|
||||
"guest",
|
||||
"actions",
|
||||
"actionSlots",
|
||||
"permissions",
|
||||
@@ -650,6 +671,14 @@ function validateContributes(
|
||||
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
|
||||
|
||||
validateTabs(contributes.tabs, errors);
|
||||
validateViews(contributes.panels, "panels", errors);
|
||||
validateViews(contributes.dashboardCards, "dashboardCards", errors);
|
||||
if (
|
||||
contributes.guest !== undefined &&
|
||||
typeof contributes.guest !== "boolean"
|
||||
) {
|
||||
errors.push('Field "contributes.guest" must be a boolean');
|
||||
}
|
||||
validatePermissions(contributes.permissions, pluginId, errors);
|
||||
validateActions(contributes.actions, errors);
|
||||
validateActionSlots(contributes.actionSlots, errors);
|
||||
@@ -884,6 +913,30 @@ function validateTabs(tabs: unknown, errors: string[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function validateViews(views: unknown, field: string, errors: string[]): void {
|
||||
if (views === undefined) return;
|
||||
if (!Array.isArray(views)) {
|
||||
errors.push(`Field "contributes.${field}" must be an array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
views.forEach((raw, index) => {
|
||||
const where = `contributes.${field}[${index}]`;
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${where} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(raw, ["id", "titleKey", "icon"], where, errors);
|
||||
if (requireString(raw.id, `${where}.id`, errors)) {
|
||||
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
|
||||
seen.add(raw.id);
|
||||
}
|
||||
requireString(raw.titleKey, `${where}.titleKey`, errors);
|
||||
if (raw.icon !== undefined)
|
||||
requireString(raw.icon, `${where}.icon`, errors);
|
||||
});
|
||||
}
|
||||
|
||||
function validatePermissions(
|
||||
permissions: unknown,
|
||||
pluginId: string | undefined,
|
||||
|
||||
@@ -443,43 +443,88 @@ export function createMockCtx(
|
||||
}
|
||||
|
||||
/**
|
||||
* A TermixApp double for frontend tests.
|
||||
* Rendering a plugin frontend in a test.
|
||||
*
|
||||
* A7 builds the real registration surface; until then this records what a
|
||||
* plugin's frontend entry registered, which is all a test can assert about a
|
||||
* loader that does not exist yet. Deliberately free of any render library so
|
||||
* a plugin only needs testing-library if its own tests render something.
|
||||
* renderWithApp activates the plugin against the real registration surface,
|
||||
* so a test sees exactly what the shell would: which rail items, tabs, host
|
||||
* actions and cards it registered, and each of those rendered with the SDK
|
||||
* hooks working. It is implemented by the Termix host, which is where the
|
||||
* registries live; the vitest preset points "@termix/plugin-host/testing" at
|
||||
* core's implementation.
|
||||
*/
|
||||
export interface FakeTermixApp {
|
||||
export interface RenderWithAppOptions {
|
||||
pluginId?: string;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Role permissions the current user holds. */
|
||||
permissions?: string[];
|
||||
isAdmin?: boolean;
|
||||
/** Hosts useHosts() returns. */
|
||||
hosts?: Array<Record<string, unknown>>;
|
||||
/** The plugin's locales/en.json, loaded into its namespace. */
|
||||
locales?: Record<string, unknown>;
|
||||
/** Render as an anonymous guest page. */
|
||||
guest?: boolean;
|
||||
}
|
||||
|
||||
/** A call a plugin made on the shell, recorded instead of performed. */
|
||||
export interface ShellCall {
|
||||
method: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
export interface RenderedPluginApp {
|
||||
app: TermixApp;
|
||||
/** Cleanups the plugin handed to app.onDispose. */
|
||||
disposals: Array<() => void>;
|
||||
}
|
||||
|
||||
export function renderWithApp(
|
||||
options: { pluginId?: string; manifest?: Partial<PluginManifest> } = {},
|
||||
): FakeTermixApp {
|
||||
const pluginId = options.pluginId ?? "test-plugin";
|
||||
const disposals: Array<() => void> = [];
|
||||
|
||||
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;
|
||||
|
||||
const app: TermixApp = {
|
||||
pluginId,
|
||||
manifest,
|
||||
onDispose: (dispose) => disposals.push(dispose),
|
||||
/** Everything this plugin registered, by kind. */
|
||||
registered: {
|
||||
railItems: () => Array<{ id: string; hidden?: boolean }>;
|
||||
tabs: () => string[];
|
||||
panels: () => string[];
|
||||
hostActions: () => Array<{ id: string; tabType?: string }>;
|
||||
hostEditorSections: () => string[];
|
||||
dashboardCards: () => string[];
|
||||
settingsComponents: () => string[];
|
||||
slot: (slotId: string) => string[];
|
||||
actions: () => string[];
|
||||
};
|
||||
/** Shell calls made by the plugin's code. */
|
||||
shellCalls: ShellCall[];
|
||||
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderDashboardCard: (id: string) => HTMLElement;
|
||||
renderHostEditorSection: (
|
||||
id: string,
|
||||
props?: Record<string, unknown>,
|
||||
) => HTMLElement;
|
||||
renderSettingsComponent: (
|
||||
componentId: string,
|
||||
props?: Record<string, unknown>,
|
||||
) => HTMLElement;
|
||||
/** Renders a slot the way its owner would. */
|
||||
renderSlot: (slotId: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
/** Runs deactivate and every disposer, as disabling the plugin does. */
|
||||
deactivate: () => Promise<void>;
|
||||
}
|
||||
|
||||
return { app, disposals };
|
||||
export type FrontendPluginModule = {
|
||||
activate: (app: TermixApp) => void | Promise<void>;
|
||||
deactivate?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
interface PluginTestHost {
|
||||
renderPlugin: (
|
||||
plugin: FrontendPluginModule,
|
||||
options: RenderWithAppOptions,
|
||||
) => Promise<RenderedPluginApp>;
|
||||
}
|
||||
|
||||
const TEST_HOST = "@termix/plugin-host/testing";
|
||||
|
||||
export async function renderWithApp(
|
||||
plugin: FrontendPluginModule,
|
||||
options: RenderWithAppOptions = {},
|
||||
): Promise<RenderedPluginApp> {
|
||||
// A variable specifier keeps bundlers from resolving it at build time; the
|
||||
// test runner resolves it through its alias.
|
||||
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
|
||||
return host.renderPlugin(plugin, options);
|
||||
}
|
||||
@@ -63,6 +63,46 @@ if (typeof globalThis.localStorage === "undefined") {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain plugin host for components a test renders on its own: keys come
|
||||
* back as text, every permission is held, and nothing reaches a server.
|
||||
* renderWithApp installs Termix's real host instead, which takes precedence.
|
||||
*/
|
||||
const noop = () => {};
|
||||
const offline = () =>
|
||||
Promise.reject(new Error("No plugin API in a unit test; mock it"));
|
||||
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
|
||||
usePluginId: () => "test-plugin",
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
language: "en",
|
||||
}),
|
||||
usePermission: () => true,
|
||||
useSettings: () => ({ values: {}, loaded: true, save: async () => {} }),
|
||||
useHost: () => null,
|
||||
useHosts: () => ({ hosts: [], loaded: true }),
|
||||
useCurrentUser: () => null,
|
||||
useTheme: () => ({ theme: "dark" }),
|
||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||
getApi: () => ({
|
||||
get: offline,
|
||||
delete: offline,
|
||||
post: offline,
|
||||
put: offline,
|
||||
patch: offline,
|
||||
}),
|
||||
useTabs: () => ({
|
||||
openTab: noop,
|
||||
openSingletonTab: noop,
|
||||
closeTab: noop,
|
||||
getLayout: () => null,
|
||||
applyLayout: async () => ({ skipped: [] }),
|
||||
onChange: () => noop,
|
||||
onReady: () => noop,
|
||||
}),
|
||||
invokeAction: async () => undefined,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -48,9 +48,33 @@ export function pluginVitestConfig(
|
||||
);
|
||||
|
||||
const repoRoot = findRepoRoot(pluginDir);
|
||||
// Inside a Termix checkout the SDK's browser entries resolve to source and
|
||||
// the test host is core's, so renderWithApp exercises the real registries
|
||||
// and the plugin shares one SDK instance with them.
|
||||
const alias: Record<string, string> = {
|
||||
...(repoRoot
|
||||
? {
|
||||
"@termix/plugin-sdk/frontend": path.join(
|
||||
repoRoot,
|
||||
"packages",
|
||||
"plugin-sdk",
|
||||
"src",
|
||||
"frontend.ts",
|
||||
),
|
||||
"@termix/plugin-sdk/ui": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
"plugin-host",
|
||||
"sdk-ui.ts",
|
||||
),
|
||||
"@termix/plugin-host/testing": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
"plugin-host",
|
||||
"testing-host.tsx",
|
||||
),
|
||||
"@/types": path.join(repoRoot, "src", "types"),
|
||||
"@": path.join(repoRoot, "src", "ui"),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user