Archived
feat: stabilize Pi Web plugin API
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user