feat: replace core homepage widget registry with generic extension points

This commit is contained in:
LukeGus committed 2026-09-29 13:23:51 -05:00
1 parent b8bbcd2196
commit 50d43e29fb
18 files changed
+242 -147

No files matched your search

+28 -23
View File
@@ -608,21 +608,18 @@ export interface DashboardCardContribution {
component: ComponentType<DashboardCardProps>; component: ComponentType<DashboardCardProps>;
} }
export interface HomepageWidgetContribution<C = Record<string, unknown>> { /**
* One item in a named list another plugin reads (app.registerExtension). The
* reading plugin owns the point id and the item shape. Components go under
* `components` so core can scope them to the registering plugin.
*/
export interface ExtensionContribution {
id: string; id: string;
/** Set by core to the registering plugin's id; a plugin never sets this itself. */ /** Set by core to the registering plugin's id; a plugin never sets this itself. */
pluginId?: string; pluginId?: 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>; components?: Record<string, ComponentType<any>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: unknown;
editFormComponent?: ComponentType<any>;
} }
export interface SettingsComponentProps { export interface SettingsComponentProps {
@@ -818,7 +815,10 @@ export interface TermixApp extends TermixAppInfo {
binding: KeybindingDefaultContribution, binding: KeybindingDefaultContribution,
) => Disposer; ) => Disposer;
registerDashboardCard: (card: DashboardCardContribution) => Disposer; registerDashboardCard: (card: DashboardCardContribution) => Disposer;
registerHomepageWidget: (widget: HomepageWidgetContribution) => Disposer; registerExtension: (
pointId: string,
extension: ExtensionContribution,
) => Disposer;
registerSettingsComponent: ( registerSettingsComponent: (
componentId: string, componentId: string,
component: ComponentType<SettingsComponentProps>, component: ComponentType<SettingsComponentProps>,
@@ -986,8 +986,11 @@ export interface PluginHostBridge {
useHostActions: () => HostActionContribution[]; useHostActions: () => HostActionContribution[];
useActivityTypes: () => string[]; useActivityTypes: () => string[];
activityTarget: (type: string) => ActivityTargetInfo | undefined; activityTarget: (type: string) => ActivityTargetInfo | undefined;
useHomepageWidgetTypes: () => HomepageWidgetContribution[]; useExtensions: (pointId: string) => ExtensionContribution[];
homepageWidgetType: (id: string) => HomepageWidgetContribution | undefined; getExtension: (
pointId: string,
id: string,
) => ExtensionContribution | undefined;
core: PluginCoreApi; core: PluginCoreApi;
usePluginUiPreferences: (pluginId: string) => { usePluginUiPreferences: (pluginId: string) => {
values: Record<string, unknown>; values: Record<string, unknown>;
@@ -1333,19 +1336,21 @@ export function activityTarget(type: string): ActivityTargetInfo | undefined {
} }
/** /**
* Every homepage widget type any running plugin registered with * Every item any running plugin added to `pointId` with registerExtension.
* registerHomepageWidget, for the homepage plugin's own canvas and add-widget * Reactive: it re-renders as plugins enable and disable.
* menu. Reactive: it re-renders as plugins enable and disable.
*/ */
export function useHomepageWidgetTypes(): HomepageWidgetContribution[] { export function useExtensions<T extends ExtensionContribution>(
return requireHost().useHomepageWidgetTypes(); pointId: string,
): T[] {
return requireHost().useExtensions(pointId) as T[];
} }
/** A single registered homepage widget type, by id, or undefined if none. */ /** A single item added to `pointId`, by id, or undefined if none. */
export function homepageWidgetType( export function getExtension<T extends ExtensionContribution>(
pointId: string,
id: string, id: string,
): HomepageWidgetContribution | undefined { ): T | undefined {
return requireHost().homepageWidgetType(id); return requireHost().getExtension(pointId, id) as T | undefined;
} }
/** /**
+2 -2
View File
@@ -129,8 +129,8 @@ const offline = () =>
useHostActions: () => [], useHostActions: () => [],
useActivityTypes: () => [], useActivityTypes: () => [],
activityTarget: () => undefined, activityTarget: () => undefined,
useHomepageWidgetTypes: () => [], useExtensions: () => [],
homepageWidgetType: () => undefined, getExtension: () => undefined,
usePluginUiPreferences: () => ({ values: {}, set: noop }), usePluginUiPreferences: () => ({ values: {}, set: noop }),
core: { core: {
logActivity: async () => {}, logActivity: async () => {},
+1 -2
View File
@@ -57,6 +57,5 @@ export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
defaultConfig: { hostId: 0 }, defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 }, defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 }, minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
component: DockerWidget, components: { view: DockerWidget, editForm: DockerWidgetEditForm },
editFormComponent: DockerWidgetEditForm,
}; };
+17 -5
View File
@@ -1,4 +1,5 @@
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend"; import type { ComponentType, ReactNode } from "react";
import type { ExtensionContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */ /** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30; export const GRID_SIZE = 30;
@@ -18,7 +19,18 @@ export interface WidgetEditFormProps<C> {
onChange: (config: C) => void; onChange: (config: C) => void;
} }
export type WidgetDefinition<C> = Omit< /** What app.registerExtension("homepage.widgets", ...) takes. */
HomepageWidgetContribution<C>, export interface WidgetDefinition<C> extends ExtensionContribution {
"defaultConfig" id: string;
> & { defaultConfig: C }; name: string;
description: string;
category: "links" | "info" | "system" | "monitoring";
icon: ReactNode;
defaultConfig: C;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
components: {
view: ComponentType<WidgetComponentProps<C>>;
editForm?: ComponentType<WidgetEditFormProps<C>>;
};
}
+1 -4
View File
@@ -1,7 +1,6 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { Box } from "lucide-react"; import { Box } from "lucide-react";
import type { import type {
HomepageWidgetContribution,
StandaloneViewProps, StandaloneViewProps,
TabProps, TabProps,
TermixApp, TermixApp,
@@ -57,9 +56,7 @@ export function activate(app: TermixApp): void {
when: (host) => canUse && !!host.enableSsh && dockerEnabled(host), when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
}); });
app.registerHomepageWidget( app.registerExtension("homepage.widgets", dockerWidget);
dockerWidget as unknown as HomepageWidgetContribution,
);
app.registerSlotContribution("onboarding.features", { app.registerSlotContribution("onboarding.features", {
actionId: "docker.feature", actionId: "docker.feature",
@@ -1,4 +1,5 @@
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend"; import type { ComponentType, ReactNode } from "react";
import type { ExtensionContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */ /** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30; export const GRID_SIZE = 30;
@@ -18,7 +19,18 @@ export interface WidgetEditFormProps<C> {
onChange: (config: C) => void; onChange: (config: C) => void;
} }
export type WidgetDefinition<C> = Omit< /** What app.registerExtension("homepage.widgets", ...) takes. */
HomepageWidgetContribution<C>, export interface WidgetDefinition<C> extends ExtensionContribution {
"defaultConfig" id: string;
> & { defaultConfig: C }; name: string;
description: string;
category: "links" | "info" | "system" | "monitoring";
icon: ReactNode;
defaultConfig: C;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
components: {
view: ComponentType<WidgetComponentProps<C>>;
editForm?: ComponentType<WidgetEditFormProps<C>>;
};
}
+11 -5
View File
@@ -8,7 +8,11 @@ import {
type TabProps, type TabProps,
type TermixApp, type TermixApp,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import { GRID_SIZE } from "./homepage/homepage.js"; import {
GRID_SIZE,
type FileManagerWidgetConfig,
type WidgetDefinition,
} from "./homepage/homepage.js";
import { FileManager } from "./FileManager.tsx"; import { FileManager } from "./FileManager.tsx";
import FileManagerApp from "./FileManagerApp.tsx"; import FileManagerApp from "./FileManagerApp.tsx";
import { SftpTransferTab } from "./SftpTransferTab.tsx"; import { SftpTransferTab } from "./SftpTransferTab.tsx";
@@ -113,7 +117,7 @@ export function activate(app: TermixApp): void {
fileManagerHostSetting(host, "enableFileManager", true), fileManagerHostSetting(host, "enableFileManager", true),
}); });
app.registerHomepageWidget({ app.registerExtension("homepage.widgets", {
id: "file_manager_widget", id: "file_manager_widget",
name: "File Manager", name: "File Manager",
description: "Embedded SFTP file manager for a configured host", description: "Embedded SFTP file manager for a configured host",
@@ -122,9 +126,11 @@ export function activate(app: TermixApp): void {
defaultConfig: { hostId: 0 }, defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 }, defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 }, minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
component: FileManagerWidget as never, components: {
editFormComponent: FileManagerWidgetEditForm as never, view: FileManagerWidget,
}); editForm: FileManagerWidgetEditForm,
},
} satisfies WidgetDefinition<FileManagerWidgetConfig>);
const openHost = ((host: PluginHostRecord | null, path?: string) => const openHost = ((host: PluginHostRecord | null, path?: string) =>
openHostAction(app, host, path)) as never; openHostAction(app, host, path)) as never;
+6 -2
View File
@@ -10,7 +10,11 @@ import { HomepagePreviewCard } from "./HomepagePreviewCard.js";
import { ServiceLinksCard } from "./ServiceLinksCard.js"; import { ServiceLinksCard } from "./ServiceLinksCard.js";
import { DashboardHomepageView } from "./DashboardHomepageView.js"; import { DashboardHomepageView } from "./DashboardHomepageView.js";
import { setHomepageApi } from "./api.js"; import { setHomepageApi } from "./api.js";
import { drainQueuedWidgets } from "./widgets/WidgetRegistry.js"; import {
WIDGET_POINT,
drainQueuedWidgets,
toExtension,
} from "./widgets/WidgetRegistry.js";
// Side-effect imports so widgets register themselves. // Side-effect imports so widgets register themselves.
import "./widgets/ServiceLinkWidget"; import "./widgets/ServiceLinkWidget";
@@ -54,7 +58,7 @@ export function activate(app: TermixApp): void {
app.onDispose(() => setHomepageApi(null)); app.onDispose(() => setHomepageApi(null));
for (const widget of drainQueuedWidgets()) { for (const widget of drainQueuedWidgets()) {
app.registerHomepageWidget(widget as never); app.registerExtension(WIDGET_POINT, toExtension(widget));
} }
app.registerTab("homepage", HomepageTab as ComponentType<TabProps>, { app.registerTab("homepage", HomepageTab as ComponentType<TabProps>, {
@@ -1,16 +1,22 @@
import { import {
homepageWidgetType, getExtension,
useHomepageWidgetTypes, useExtensions,
type HomepageWidgetContribution, type ExtensionContribution,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import { useMemo } from "react";
import type { WidgetTypeDefinition, WidgetTypeId } from "../types.js"; import type { WidgetTypeDefinition, WidgetTypeId } from "../types.js";
/**
* Other plugins add widgets with app.registerExtension(WIDGET_POINT, ...),
* putting the widget under components.view and its edit form under
* components.editForm. Core only stores the list, so a plugin's widgets
* stay registered while this plugin is off.
*/
export const WIDGET_POINT = "homepage.widgets";
/** /**
* Each widget module calls this at import time (before activate(app) runs), * Each widget module calls this at import time (before activate(app) runs),
* so the definitions are queued here and app.registerHomepageWidget is * so the definitions are queued here and registered from activate.
* called for each one from activate. Core owns the actual registry, the same
* one other plugins (docker, tunnels, host-metrics, file-manager) register
* their own widgets into.
*/ */
const queued: WidgetTypeDefinition[] = []; const queued: WidgetTypeDefinition[] = [];
@@ -23,18 +29,42 @@ export function drainQueuedWidgets(): WidgetTypeDefinition[] {
return queued.splice(0, queued.length); return queued.splice(0, queued.length);
} }
export function toExtension(def: WidgetTypeDefinition): ExtensionContribution {
const { component, editFormComponent, ...rest } = def;
return {
...rest,
components: editFormComponent
? { view: component, editForm: editFormComponent }
: { view: component },
};
}
function toDefinition( function toDefinition(
widget: HomepageWidgetContribution | undefined, extension: ExtensionContribution | undefined,
): WidgetTypeDefinition | undefined { ): WidgetTypeDefinition | undefined {
return widget as unknown as WidgetTypeDefinition | undefined; const view = extension?.components?.view;
if (!extension || !view) return undefined;
const { components, ...rest } = extension;
return {
...(rest as unknown as WidgetTypeDefinition),
component: view,
editFormComponent: components?.editForm,
};
} }
export function getWidgetType( export function getWidgetType(
id: WidgetTypeId, id: WidgetTypeId,
): WidgetTypeDefinition | undefined { ): WidgetTypeDefinition | undefined {
return toDefinition(homepageWidgetType(id)); return toDefinition(getExtension(WIDGET_POINT, id));
} }
export function useWidgetTypes(): WidgetTypeDefinition[] { export function useWidgetTypes(): WidgetTypeDefinition[] {
return useHomepageWidgetTypes() as unknown as WidgetTypeDefinition[]; const extensions = useExtensions(WIDGET_POINT);
return useMemo(
() =>
extensions
.map(toDefinition)
.filter((def): def is WidgetTypeDefinition => !!def),
[extensions],
);
} }
@@ -236,8 +236,7 @@ export const metricsChartWidget: WidgetDefinition<MetricsChartConfig> = {
}, },
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 }, defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 }, minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
component: MetricsChartWidget, components: { view: MetricsChartWidget, editForm: MetricsChartEditForm },
editFormComponent: MetricsChartEditForm,
}; };
export { MetricsChartWidget }; export { MetricsChartWidget };
+1 -4
View File
@@ -1,7 +1,6 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { Activity, Server } from "lucide-react"; import { Activity, Server } from "lucide-react";
import type { import type {
HomepageWidgetContribution,
HostEditorSectionProps, HostEditorSectionProps,
PluginHostRecord, PluginHostRecord,
StandaloneViewProps, StandaloneViewProps,
@@ -100,9 +99,7 @@ export async function activate(app: TermixApp): Promise<void> {
component: MetricsHostSection, component: MetricsHostSection,
}); });
app.registerHomepageWidget( app.registerExtension("homepage.widgets", metricsChartWidget);
metricsChartWidget as unknown as HomepageWidgetContribution,
);
// Live CPU, memory and disk bars in the terminal toolbar's expanded view. // Live CPU, memory and disk bars in the terminal toolbar's expanded view.
app.registerSlotContribution("terminal.toolbarStatus", { app.registerSlotContribution("terminal.toolbarStatus", {
+17 -5
View File
@@ -1,4 +1,5 @@
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend"; import type { ComponentType, ReactNode } from "react";
import type { ExtensionContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */ /** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30; export const GRID_SIZE = 30;
@@ -25,7 +26,18 @@ export interface WidgetEditFormProps<C> {
onChange: (config: C) => void; onChange: (config: C) => void;
} }
export type WidgetDefinition<C> = Omit< /** What app.registerExtension("homepage.widgets", ...) takes. */
HomepageWidgetContribution<C>, export interface WidgetDefinition<C> extends ExtensionContribution {
"defaultConfig" id: string;
> & { defaultConfig: C }; name: string;
description: string;
category: "links" | "info" | "system" | "monitoring";
icon: ReactNode;
defaultConfig: C;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
components: {
view: ComponentType<WidgetComponentProps<C>>;
editForm?: ComponentType<WidgetEditFormProps<C>>;
};
}
+4 -9
View File
@@ -1,10 +1,6 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { Network, Plug } from "lucide-react"; import { Network, Plug } from "lucide-react";
import type { import type { TabProps, TermixApp } from "@termix/plugin-sdk/frontend";
HomepageWidgetContribution,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { TunnelTab } from "./TunnelTab"; import { TunnelTab } from "./TunnelTab";
import { HostTunnelsSection } from "./HostTunnelsSection"; import { HostTunnelsSection } from "./HostTunnelsSection";
import { import {
@@ -84,7 +80,7 @@ export function activate(app: TermixApp): void {
icon: Plug as ComponentType<{ className?: string }>, icon: Plug as ComponentType<{ className?: string }>,
}); });
app.registerHomepageWidget({ app.registerExtension("homepage.widgets", {
id: "tunnel_widget", id: "tunnel_widget",
name: "Tunnel Manager", name: "Tunnel Manager",
description: "Embedded SSH tunnel manager for a configured host", description: "Embedded SSH tunnel manager for a configured host",
@@ -93,9 +89,8 @@ export function activate(app: TermixApp): void {
defaultConfig: { hostId: 0 }, defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 10 }, defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 }, minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
component: TunnelWidget, components: { view: TunnelWidget, editForm: TunnelWidgetEditForm },
editFormComponent: TunnelWidgetEditForm, });
} as HomepageWidgetContribution);
// The dashboard's active tunnel counter. // The dashboard's active tunnel counter.
app.registerAction( app.registerAction(
+11 -11
View File
@@ -35,7 +35,7 @@ import {
registerDashboardCard, registerDashboardCard,
type DashboardCardRenderProps, type DashboardCardRenderProps,
} from "@/dashboard/dashboard-cards-registry"; } from "@/dashboard/dashboard-cards-registry";
import { registerHomepageWidgetType } from "./homepage-widget-registry"; import { registerExtension } from "./extension-registry";
import { registerSettingsComponent } from "@/settings/settings-components"; import { registerSettingsComponent } from "@/settings/settings-components";
import { import {
declareActionSlot, declareActionSlot,
@@ -45,7 +45,6 @@ import {
} from "@/shell/action-registry"; } from "@/shell/action-registry";
import { pluginApiFor, pluginFetch, pluginWsUrl } from "@/lib/plugin-transport"; import { pluginApiFor, pluginFetch, pluginWsUrl } from "@/lib/plugin-transport";
import { registerPluginComponent } from "./component-registry"; import { registerPluginComponent } from "./component-registry";
import type { RegisteredHomepageWidget } from "./homepage-widget-registry";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import {
registerLoginMethod, registerLoginMethod,
@@ -360,16 +359,17 @@ export function createPluginApp(
); );
}, },
registerHomepageWidget(widget) { registerExtension(pointId, extension) {
const components = extension.components
? Object.fromEntries(
Object.entries(extension.components).map(([name, component]) => [
name,
scoped(component),
]),
)
: undefined;
return track( return track(
registerHomepageWidgetType({ registerExtension(pointId, { ...extension, components, pluginId }),
...widget,
pluginId,
component: scoped(widget.component),
editFormComponent: widget.editFormComponent
? scoped(widget.editFormComponent)
: undefined,
} as unknown as RegisteredHomepageWidget),
); );
}, },
+5 -10
View File
@@ -2,15 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { enabledHostProtocols } from "@/sidebar/host-protocols"; import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useHostActions } from "@/sidebar/host-contributions"; import { useHostActions } from "@/sidebar/host-contributions";
import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry"; import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry";
import { import { getExtension, useExtensions } from "./extension-registry";
getHomepageWidgetType,
useHomepageWidgetTypes,
} from "./homepage-widget-registry";
import { useTranslation as useI18nTranslation } from "react-i18next"; import { useTranslation as useI18nTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
__setPluginHost, __setPluginHost,
type HomepageWidgetContribution, type ExtensionContribution,
type HostActionContribution, type HostActionContribution,
type PluginHostBridge, type PluginHostBridge,
type PluginHostRecord, type PluginHostRecord,
@@ -380,11 +377,9 @@ export const pluginHostBridge: PluginHostBridge = {
return { icon: def.icon, tab: def.id, titleKey: def.titleKey }; return { icon: def.icon, tab: def.id, titleKey: def.titleKey };
}, },
useHomepageWidgetTypes: () => useExtensions: (pointId) => useExtensions(pointId) as ExtensionContribution[],
useHomepageWidgetTypes() as unknown as HomepageWidgetContribution[], getExtension: (pointId, id) =>
homepageWidgetType: (id) => getExtension(pointId, id) as ExtensionContribution | undefined,
getHomepageWidgetType(id) as unknown as
HomepageWidgetContribution | undefined,
usePluginUiPreferences: (pluginId) => { usePluginUiPreferences: (pluginId) => {
const ctx = useUiPreferencesContext(); const ctx = useUiPreferencesContext();
+50
View File
@@ -0,0 +1,50 @@
import { createRegistry, type Registry } from "@/lib/registry";
/**
* Named lists any plugin can add to through app.registerExtension. The plugin
* that reads a point decides its id and item shape; core only stores them, so
* contributions survive the reader being off and core never learns what a
* point is for.
*/
export interface RegisteredExtension {
id: string;
pluginId?: string;
[key: string]: unknown;
}
const points = new Map<string, Registry<RegisteredExtension>>();
function point(id: string): Registry<RegisteredExtension> {
let registry = points.get(id);
if (!registry) {
registry = createRegistry<RegisteredExtension>();
points.set(id, registry);
}
return registry;
}
export function registerExtension(
pointId: string,
extension: RegisteredExtension,
): () => void {
return point(pointId).register(extension);
}
export function getExtension(
pointId: string,
id: string,
): RegisteredExtension | undefined {
return point(pointId).get(id);
}
export function listExtensions(pointId: string): RegisteredExtension[] {
return point(pointId).list();
}
export function useExtensions(pointId: string): RegisteredExtension[] {
return point(pointId).useList();
}
export function resetExtensions(): void {
for (const registry of points.values()) registry.reset();
}
@@ -1,31 +0,0 @@
import type { ComponentType } from "react";
import { createRegistry } from "@/lib/registry";
/**
* A homepage widget type any plugin contributes through
* app.registerHomepageWidget. Core owns this registry so the homepage
* plugin's canvas can read it without every widget-contributing plugin
* depending on homepage being enabled.
*/
export interface RegisteredHomepageWidget {
id: string;
pluginId?: string;
name: string;
description: string;
category: "links" | "info" | "system" | "monitoring";
icon: React.ReactNode;
defaultConfig: Record<string, unknown>;
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>;
}
const registry = createRegistry<RegisteredHomepageWidget>();
export const registerHomepageWidgetType = registry.register;
export const getHomepageWidgetType = registry.get;
export const useHomepageWidgetTypes = registry.useList;
export const resetHomepageWidgetTypes = registry.reset;
+29 -16
View File
@@ -3,9 +3,9 @@ import { useState } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Puzzle } from "lucide-react"; import { Puzzle } from "lucide-react";
import { import {
homepageWidgetType, getExtension,
useActivityTypes, useActivityTypes,
useHomepageWidgetTypes, useExtensions,
useHostActions, useHostActions,
usePermission, usePermission,
useTranslation, useTranslation,
@@ -39,7 +39,7 @@ import { resetRegisteredRailItems } from "@/sidebar/rail-items";
import { resetTabTypes } from "@/shell/tab-registry"; import { resetTabTypes } from "@/shell/tab-registry";
import { resetPanels } from "@/shell/panel-registry"; import { resetPanels } from "@/shell/panel-registry";
import { resetDashboardCards } from "@/dashboard/dashboard-cards-registry"; import { resetDashboardCards } from "@/dashboard/dashboard-cards-registry";
import { resetHomepageWidgetTypes } from "@/plugin-host/homepage-widget-registry"; import { resetExtensions } from "@/plugin-host/extension-registry";
import { resetSettingsComponents } from "@/settings/settings-components"; import { resetSettingsComponents } from "@/settings/settings-components";
import { resetActionRegistry } from "@/shell/action-registry"; import { resetActionRegistry } from "@/shell/action-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store"; import { resetPluginStore } from "@/plugin-host/plugin-store";
@@ -132,16 +132,11 @@ function activate(app: TermixApp) {
), ),
}); });
app.registerSettingsComponent("probe", PermissionProbe); app.registerSettingsComponent("probe", PermissionProbe);
app.registerHomepageWidget({ app.registerExtension("fixture.widgets", {
id: "fixture-widget", id: "fixture-widget",
name: "Fixture Widget", name: "Fixture Widget",
description: "A fixture widget",
category: "info",
icon: <Puzzle size={14} />, icon: <Puzzle size={14} />,
defaultConfig: {}, components: { view: () => <span>fixture widget</span> },
defaultSize: { w: 4, h: 4 },
minSize: { w: 2, h: 2 },
component: () => <span>fixture widget</span>,
}); });
} }
@@ -168,7 +163,7 @@ afterEach(async () => {
resetHostEditorSections(); resetHostEditorSections();
resetPaletteEntries(); resetPaletteEntries();
resetPluginStore(); resetPluginStore();
resetHomepageWidgetTypes(); resetExtensions();
}); });
const host = (overrides: Partial<Host> = {}) => const host = (overrides: Partial<Host> = {}) =>
@@ -294,15 +289,33 @@ describe("registries through the app object", () => {
expect(await screen.findByText(/fixture_activity/)).toBeTruthy(); expect(await screen.findByText(/fixture_activity/)).toBeTruthy();
}); });
it("registers a homepage widget type readable through the SDK", async () => { it("adds an extension other plugins read by point id", async () => {
await mount(); await mount();
expect(homepageWidgetType("fixture-widget")?.name).toBe("Fixture Widget"); const widget = getExtension("fixture.widgets", "fixture-widget");
expect(widget?.name).toBe("Fixture Widget");
expect(widget?.pluginId).toBe("fixture");
expect(getExtension("other.point", "fixture-widget")).toBeUndefined();
const View = widget?.components?.view;
function Probe() { function Probe() {
const types = useHomepageWidgetTypes(); const items = useExtensions("fixture.widgets");
return <span>{types.map((w) => w.id).join(",")}</span>; return <span>{items.map((w) => w.id).join(",")}</span>;
} }
render(<Probe />); render(
<>
<Probe />
{View && <View />}
</>,
);
expect(await screen.findByText("fixture-widget")).toBeTruthy(); expect(await screen.findByText("fixture-widget")).toBeTruthy();
expect(await screen.findByText("fixture widget")).toBeTruthy();
});
it("drops a plugin's extensions when it deactivates", async () => {
await mount();
expect(getExtension("fixture.widgets", "fixture-widget")).toBeTruthy();
await rendered?.deactivate();
rendered = null;
expect(getExtension("fixture.widgets", "fixture-widget")).toBeUndefined();
}); });
it("offers a per-host palette entry that runs against the shell", async () => { it("offers a per-host palette entry that runs against the shell", async () => {