feat: stabilize Pi Web plugin API

This commit is contained in:
Federico Jaramillo Martinez
2026-05-17 22:46:44 +02:00
parent c77c47c1d5
commit 30995797ea
24 changed files with 764 additions and 496 deletions
+3 -3
View File
@@ -282,7 +282,7 @@ export class PiWebApp extends LitElement {
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
const workspace = this.state.selectedWorkspace;
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.(workspace) ?? true));
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.({ workspace, state: this.state }) ?? true));
}
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
@@ -322,7 +322,7 @@ export class PiWebApp extends LitElement {
private async loadExternalPlugins(): Promise<void> {
try {
for (const plugin of await loadExternalPlugins()) this.plugins.register(plugin);
for (const registration of await loadExternalPlugins()) this.plugins.register(registration);
this.requestUpdate();
} catch (error) {
console.warn("Failed to load external Pi Web plugins", error);
@@ -436,7 +436,7 @@ export class PiWebApp extends LitElement {
function createPluginRegistry(): PluginRegistry {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
return registry;
}
+1 -1
View File
@@ -34,7 +34,7 @@ export class WorkspacePanel extends LitElement {
override render() {
const workspace = this.workspace;
if (workspace === undefined) return html`<section class="empty">Select a workspace.</section>`;
const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true);
const visiblePanels = this.panels;
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
const context = this.createPanelContext(workspace);
return html`
+5 -3
View File
@@ -3,10 +3,12 @@ import { createCoreActions } from "./actions";
import { createCoreWorkspacePanels } from "./panels";
export const corePlugin: PiWebPlugin = {
id: "core",
apiVersion: 1,
name: "Pi Web Core",
activate: () => ({
actions: createCoreActions(),
workspacePanels: createCoreWorkspacePanels(),
contributions: {
actions: createCoreActions(),
workspacePanels: createCoreWorkspacePanels(),
},
}),
};
+1 -1
View File
@@ -14,7 +14,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
id: "workspace.git",
title: "Git",
order: 20,
visible: (workspace) => workspace.isGitRepo,
visible: ({ workspace }) => workspace.isGitRepo,
render: renderGit,
},
{
+36 -34
View File
@@ -2,42 +2,44 @@ import { html } from "lit";
import type { PiWebPlugin } from "../types";
export const examplePlugin: PiWebPlugin = {
id: "example",
apiVersion: 1,
name: "Example Plugin",
activate: () => ({
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "Example",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
window.alert(path);
contributions: {
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "Example",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
window.alert(path);
},
},
},
],
workspaceLabelContributions: [
{
id: "workspace.example-label",
order: 100,
items: (context) => ({ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }),
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: (context) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
},
],
],
workspaceLabels: [
{
id: "workspace.example-label",
order: 100,
items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }],
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: (context) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
},
],
},
}),
};
+10 -21
View File
@@ -1,7 +1,7 @@
import { html } from "lit";
import type { PiWebPlugin } from "./types";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
interface PluginManifestEntry {
id: string;
module: string;
}
@@ -9,33 +9,22 @@ interface PluginManifest {
plugins: PluginManifestEntry[];
}
declare global {
interface Window {
piWebPluginApi?: {
apiVersion: 1;
html: typeof html;
};
}
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPlugin[]> {
window.piWebPluginApi = { apiVersion: 1, html };
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPluginRegistration[]> {
const manifest = await fetchPluginManifest(manifestUrl);
if (manifest === undefined) return [];
const plugins: PiWebPlugin[] = [];
const registrations: PiWebPluginRegistration[] = [];
for (const entry of manifest.plugins) {
try {
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
const plugin = parsePluginModule(module, moduleUrl);
if (plugin !== undefined) plugins.push(plugin);
registrations.push({ id: entry.id, plugin });
} catch (error) {
console.warn(`Failed to load Pi Web plugin ${entry.module}`, error);
}
}
return plugins;
return registrations;
}
async function fetchPluginManifest(manifestUrl: string): Promise<PluginManifest | undefined> {
@@ -49,13 +38,13 @@ function parseManifest(value: unknown): PluginManifest {
if (!isRecord(value) || !Array.isArray(value["plugins"])) throw new Error("Invalid plugin manifest");
return {
plugins: value["plugins"].map((entry) => {
if (!isRecord(entry) || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
return { module: entry["module"] };
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
return { id: entry["id"], module: entry["module"] };
}),
};
}
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin | undefined {
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
const plugin = module["default"];
if (!isPiWebPlugin(plugin)) throw new Error(`Plugin module ${moduleUrl} default export is not a PiWebPlugin`);
@@ -63,7 +52,7 @@ function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin | un
}
function isPiWebPlugin(value: unknown): value is PiWebPlugin {
return isRecord(value) && typeof value["id"] === "string" && typeof value["name"] === "string" && typeof value["activate"] === "function";
return isRecord(value) && value["apiVersion"] === 1 && typeof value["name"] === "string" && typeof value["activate"] === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
+28 -18
View File
@@ -28,7 +28,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
describe("PluginRegistry", () => {
it("namespaces contribution ids with the owning plugin id", () => {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
expect(registry.getActions(createContext().context).some((action) => action.id === "core:actions.show")).toBe(true);
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
@@ -40,20 +40,25 @@ describe("PluginRegistry", () => {
expect(() => {
registry.register({
id: "example",
name: "Example",
activate: () => ({
actions: [
{ id: "duplicate", title: "One", run: () => undefined },
{ id: "duplicate", title: "Two", run: () => undefined },
],
}),
plugin: {
apiVersion: 1,
name: "Example",
activate: () => ({
contributions: {
actions: [
{ id: "duplicate", title: "One", run: () => undefined },
{ id: "duplicate", title: "Two", run: () => undefined },
],
},
}),
},
});
}).toThrow("Duplicate contribution id: example:duplicate");
});
it("evaluates core action enablement against runtime state", () => {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
const inactive = registry.getActions(createContext().context);
const active = registry.getActions(createContext({ selectedWorkspace: testWorkspace() }).context);
@@ -64,7 +69,7 @@ describe("PluginRegistry", () => {
it("routes refresh current to the active core workspace panel", () => {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({
selectedWorkspace: testWorkspace(),
workspaceTool: "core:workspace.git",
@@ -81,14 +86,19 @@ describe("PluginRegistry", () => {
const workspace = testWorkspace();
registry.register({
id: "example",
name: "Example",
activate: () => ({
workspaceLabelContributions: [
{ id: "last", order: 20, items: () => ({ type: "text", text: "last" }) },
{ id: "hidden", order: 5, visible: () => false, items: () => ({ type: "text", text: "hidden" }) },
{ id: "first", order: 10, items: () => [{ type: "link", text: "web", href: "http://localhost:5173" }] },
],
}),
plugin: {
apiVersion: 1,
name: "Example",
activate: () => ({
contributions: {
workspaceLabels: [
{ id: "last", order: 20, items: () => [{ type: "text", text: "last" }] },
{ id: "hidden", order: 5, visible: () => false, items: () => [{ type: "text", text: "hidden" }] },
{ id: "first", order: 10, items: () => [{ type: "link", text: "web", href: "http://localhost:5173" }] },
],
},
}),
},
});
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([
+18 -15
View File
@@ -1,6 +1,7 @@
import { html } from "lit";
import type { AppState } from "../appState";
import type { Workspace } from "../api";
import type { PiWebPlugin, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
const idPattern = /^[a-z][a-z0-9.-]*$/u;
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
@@ -14,24 +15,28 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
export class PluginRegistry {
private readonly actions: RegisteredPluginAction[] = [];
private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = [];
private readonly workspaceLabelContributions: QualifiedWorkspaceLabelContribution[] = [];
private readonly workspaceLabels: QualifiedWorkspaceLabelContribution[] = [];
private readonly pluginIds = new Set<string>();
private readonly contributionIds = new Set<QualifiedContributionId>();
register(plugin: PiWebPlugin): void {
this.validatePluginId(plugin.id);
if (this.pluginIds.has(plugin.id)) throw new Error(`Duplicate plugin id: ${plugin.id}`);
this.pluginIds.add(plugin.id);
register(registration: PiWebPluginRegistration): void {
const { id, plugin } = registration;
this.validatePluginId(id);
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
this.pluginIds.add(id);
const contributions = plugin.activate({ apiVersion: 1 });
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(plugin.id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(plugin.id, panel));
for (const contribution of contributions.workspaceLabelContributions ?? []) this.workspaceLabelContributions.push(this.qualifyWorkspaceLabelContribution(plugin.id, contribution));
const apiVersion: unknown = plugin.apiVersion;
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
const result = plugin.activate({ apiVersion: 1, pluginId: id, html });
const contributions = result.contributions;
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel));
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution));
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.map((action) => {
const enabled = typeof action.enabled === "function" ? action.enabled(context) : action.enabled;
const enabled = action.enabled?.(context);
const qualified: QualifiedPluginAction = {
id: action.id,
pluginId: action.pluginId,
@@ -53,13 +58,11 @@ export class PluginRegistry {
getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] {
const context = { state, workspace };
return [...this.workspaceLabelContributions]
return [...this.workspaceLabels]
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
.flatMap((contribution) => {
if (contribution.visible?.(context) === false) return [];
const items = contribution.items(context);
if (items === undefined) return [];
return Array.isArray(items) ? items : [items];
return contribution.items(context);
});
}
+23 -6
View File
@@ -6,21 +6,33 @@ import type { AppState } from "../appState";
export type PluginId = string;
export type LocalContributionId = string;
export type QualifiedContributionId = `${PluginId}:${LocalContributionId}`;
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export interface PiWebPluginRegistration {
id: PluginId;
plugin: PiWebPlugin;
}
export interface PiWebPlugin {
id: PluginId;
apiVersion: 1;
name: string;
activate: (context: PluginActivationContext) => PluginContributions;
activate: (context: PluginActivationContext) => PluginActivationResult;
}
export interface PluginActivationContext {
apiVersion: 1;
pluginId: PluginId;
html: HtmlTemplateTag;
}
export interface PluginActivationResult {
contributions: PluginContributions;
}
export interface PluginContributions {
actions?: PluginAction[];
workspacePanels?: WorkspacePanelContribution[];
workspaceLabelContributions?: WorkspaceLabelContribution[];
workspaceLabels?: WorkspaceLabelContribution[];
}
export interface PluginRuntimeContext {
@@ -45,7 +57,7 @@ export interface PluginAction {
description?: string;
shortcut?: string;
group?: string;
enabled?: boolean | ((context: PluginRuntimeContext) => boolean);
enabled?: (context: PluginRuntimeContext) => boolean;
run: (context: PluginRuntimeContext) => void | Promise<void>;
}
@@ -54,6 +66,11 @@ export interface QualifiedPluginAction extends AppAction {
localId: LocalContributionId;
}
export interface WorkspacePanelVisibilityContext {
workspace: Workspace;
state: AppState;
}
export interface WorkspacePanelContext {
workspace: Workspace;
fileTree: FileTreeEntry[];
@@ -79,7 +96,7 @@ export interface WorkspacePanelContribution {
id: LocalContributionId;
title: string;
order?: number;
visible?: (workspace: Workspace) => boolean;
visible?: (context: WorkspacePanelVisibilityContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
render: (context: WorkspacePanelContext) => TemplateResult;
}
@@ -120,7 +137,7 @@ export interface WorkspaceLabelContribution {
id: LocalContributionId;
order?: number;
visible?: (context: WorkspaceLabelContext) => boolean;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem | WorkspaceLabelItem[] | undefined;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[];
}
export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContribution {