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

+11 -11
View File
@@ -35,7 +35,7 @@ import {
registerDashboardCard,
type DashboardCardRenderProps,
} from "@/dashboard/dashboard-cards-registry";
import { registerHomepageWidgetType } from "./homepage-widget-registry";
import { registerExtension } from "./extension-registry";
import { registerSettingsComponent } from "@/settings/settings-components";
import {
declareActionSlot,
@@ -45,7 +45,6 @@ import {
} from "@/shell/action-registry";
import { pluginApiFor, pluginFetch, pluginWsUrl } from "@/lib/plugin-transport";
import { registerPluginComponent } from "./component-registry";
import type { RegisteredHomepageWidget } from "./homepage-widget-registry";
import type { LucideIcon } from "lucide-react";
import {
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(
registerHomepageWidgetType({
...widget,
pluginId,
component: scoped(widget.component),
editFormComponent: widget.editFormComponent
? scoped(widget.editFormComponent)
: undefined,
} as unknown as RegisteredHomepageWidget),
registerExtension(pointId, { ...extension, components, pluginId }),
);
},
+5 -10
View File
@@ -2,15 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useHostActions } from "@/sidebar/host-contributions";
import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry";
import {
getHomepageWidgetType,
useHomepageWidgetTypes,
} from "./homepage-widget-registry";
import { getExtension, useExtensions } from "./extension-registry";
import { useTranslation as useI18nTranslation } from "react-i18next";
import { toast } from "sonner";
import {
__setPluginHost,
type HomepageWidgetContribution,
type ExtensionContribution,
type HostActionContribution,
type PluginHostBridge,
type PluginHostRecord,
@@ -380,11 +377,9 @@ export const pluginHostBridge: PluginHostBridge = {
return { icon: def.icon, tab: def.id, titleKey: def.titleKey };
},
useHomepageWidgetTypes: () =>
useHomepageWidgetTypes() as unknown as HomepageWidgetContribution[],
homepageWidgetType: (id) =>
getHomepageWidgetType(id) as unknown as
HomepageWidgetContribution | undefined,
useExtensions: (pointId) => useExtensions(pointId) as ExtensionContribution[],
getExtension: (pointId, id) =>
getExtension(pointId, id) as ExtensionContribution | undefined,
usePluginUiPreferences: (pluginId) => {
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 { Puzzle } from "lucide-react";
import {
homepageWidgetType,
getExtension,
useActivityTypes,
useHomepageWidgetTypes,
useExtensions,
useHostActions,
usePermission,
useTranslation,
@@ -39,7 +39,7 @@ import { resetRegisteredRailItems } from "@/sidebar/rail-items";
import { resetTabTypes } from "@/shell/tab-registry";
import { resetPanels } from "@/shell/panel-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 { resetActionRegistry } from "@/shell/action-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
@@ -132,16 +132,11 @@ function activate(app: TermixApp) {
),
});
app.registerSettingsComponent("probe", PermissionProbe);
app.registerHomepageWidget({
app.registerExtension("fixture.widgets", {
id: "fixture-widget",
name: "Fixture Widget",
description: "A fixture widget",
category: "info",
icon: <Puzzle size={14} />,
defaultConfig: {},
defaultSize: { w: 4, h: 4 },
minSize: { w: 2, h: 2 },
component: () => <span>fixture widget</span>,
components: { view: () => <span>fixture widget</span> },
});
}
@@ -168,7 +163,7 @@ afterEach(async () => {
resetHostEditorSections();
resetPaletteEntries();
resetPluginStore();
resetHomepageWidgetTypes();
resetExtensions();
});
const host = (overrides: Partial<Host> = {}) =>
@@ -294,15 +289,33 @@ describe("registries through the app object", () => {
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();
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() {
const types = useHomepageWidgetTypes();
return <span>{types.map((w) => w.id).join(",")}</span>;
const items = useExtensions("fixture.widgets");
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();
});
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 () => {