feat: load machine-scoped remote plugins

This commit is contained in:
Federico Jaramillo Martinez
2026-06-05 09:29:48 +02:00
parent 9c3dafc4d4
commit b9be7de206
13 changed files with 466 additions and 24 deletions
+32 -3
View File
@@ -19,7 +19,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
@@ -145,6 +145,8 @@ export class PiWebApp extends LitElement {
private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined;
private readonly plugins = createPluginRegistry();
private readonly loadedMachinePluginIds = new Set<string>();
private readonly machinePluginLoadPromises = new Map<string, Promise<void>>();
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isRefreshingApp = false;
@@ -331,6 +333,8 @@ export class PiWebApp extends LitElement {
this.restoringRouteTerminalId = routeSurface.selectedTerminalId;
try {
await this.restoreRouteMachine(route, false);
const selectedMachinePluginLoad = this.loadPluginsForSelectedMachine();
if (route.tool?.startsWith("machine.") === true) await selectedMachinePluginLoad;
if (!this.isCurrentRouteRestore(restoreSeq)) return;
this.setState({
workspaceTool: route.tool ?? this.state.workspaceTool,
@@ -710,6 +714,7 @@ export class PiWebApp extends LitElement {
this.connectRealtime();
this.activeTerminalIds.clear();
this.git.updatePolling();
void this.loadPluginsForSelectedMachine();
}
private refreshSelectedWorkspaceTool(tool: QualifiedContributionId): void {
@@ -945,8 +950,30 @@ export class PiWebApp extends LitElement {
}
private async loadExternalPlugins(): Promise<void> {
await this.registerExternalPlugins("PI WEB plugins", () => loadExternalPlugins());
}
private async loadPluginsForSelectedMachine(): Promise<void> {
const machine = this.state.selectedMachine;
if (machine?.kind !== "remote") return;
await this.loadPluginsForMachine(machine);
}
private async loadPluginsForMachine(machine: Machine): Promise<void> {
if (machine.kind !== "remote" || this.loadedMachinePluginIds.has(machine.id)) return;
const existing = this.machinePluginLoadPromises.get(machine.id);
if (existing !== undefined) return existing;
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id }))
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
this.machinePluginLoadPromises.set(machine.id, load);
await load;
}
private async registerExternalPlugins(label: string, load: () => Promise<PiWebPluginRegistration[]>): Promise<boolean> {
try {
const registrations = await loadExternalPlugins();
const registrations = await load();
for (const registration of registrations) {
try {
this.plugins.register(registration);
@@ -956,8 +983,10 @@ export class PiWebApp extends LitElement {
}
this.applyPreferredTheme(false);
this.requestUpdate();
return true;
} catch (error) {
console.warn("Failed to load external PI WEB plugins", error);
console.warn(`Failed to load ${label}`, error);
return false;
}
}
+11 -2
View File
@@ -1,3 +1,4 @@
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
interface PluginManifestEntry {
@@ -9,7 +10,11 @@ interface PluginManifest {
plugins: PluginManifestEntry[];
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPluginRegistration[]> {
export interface LoadExternalPluginsOptions {
machineId?: string;
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
const manifest = await fetchPluginManifest(manifestUrl);
if (manifest === undefined) return [];
@@ -19,7 +24,11 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
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);
registrations.push({ id: entry.id, plugin });
registrations.push({
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
plugin,
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
});
} catch (error) {
console.warn(`Failed to load PI WEB plugin ${entry.module}`, error);
}
+72 -1
View File
@@ -1,11 +1,13 @@
import { html } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import { corePlugin } from "./core";
import { PluginRegistry } from "./registry";
import { themePackPlugin } from "./themes";
import type { PluginRuntimeContext, ThemeTokens } from "./types";
import type { PluginRuntimeContext, ThemeTokens, WorkspacePanelContext } from "./types";
function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
@@ -289,12 +291,81 @@ describe("PluginRegistry", () => {
{ type: "text", text: "last" },
]);
});
it("only exposes machine-scoped plugin contributions for their machine", () => {
const registry = new PluginRegistry();
const pluginId = machineScopedPluginId("remote-1", "project-tools");
const workspace = testWorkspace();
registry.register({
id: pluginId,
machineId: "remote-1",
sourcePluginId: "project-tools",
plugin: {
apiVersion: 1,
name: "Project Tools",
activate: () => ({
contributions: {
actions: [{ id: "do-thing", title: "Do Thing", run: () => undefined }],
workspacePanels: [{ id: "workspace.tools", title: "Tools", render: () => html`<p>Tools</p>` }],
workspaceLabels: [{ id: "badge", items: () => [{ type: "text", text: "remote" }] }],
themes: [{ id: "remote-theme", name: "Remote Theme", colorScheme: "dark", tokens: testThemeTokens() }],
},
}),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).not.toContain(`${pluginId}:do-thing`);
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toContain(`${pluginId}:do-thing`);
const panel = registry.getWorkspacePanels().find((candidate) => candidate.id === `${pluginId}:workspace.tools`);
expect(panel?.visible?.(createWorkspacePanelContext("local"))).toBe(false);
expect(panel?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([]);
expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "remote" }]);
expect(registry.getThemes()).toEqual([]);
});
});
function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
}
function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
const workspace = testWorkspace();
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
workspace,
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
files: { readFile: vi.fn() },
terminal: { open: vi.fn(), runCommand: vi.fn() },
host: { requestRender: vi.fn() },
fileTree: [],
expandedDirs: {},
selectedFilePath: undefined,
selectedFileContent: undefined,
fileTreeStale: false,
gitStatus: undefined,
selectedDiffPath: undefined,
selectedDiff: undefined,
selectedStagedDiff: undefined,
gitStale: false,
activeTerminalCount: 0,
selectedTerminalId: undefined,
terminalAutoStart: false,
onRefreshFiles: vi.fn(),
onExpandDir: vi.fn(),
onSelectFile: vi.fn(),
onRefreshGit: vi.fn(),
onSelectDiff: vi.fn(),
onSelectTerminal: vi.fn(),
};
}
function testMachine(id: string) {
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
}
function testSession(patch: Partial<SessionInfo> = {}): SessionInfo {
return {
id: "s1",
+36 -13
View File
@@ -12,6 +12,7 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
id: QualifiedContributionId;
pluginId: string;
localId: string;
machineId?: string;
};
export class PluginRegistry {
@@ -33,21 +34,24 @@ export class PluginRegistry {
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg });
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));
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action, registration.machineId));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel, registration.machineId));
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution, registration.machineId));
if (registration.machineId === undefined) {
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
}
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.map((action) => {
return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context))).map((action) => {
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext);
const qualified: QualifiedPluginAction = {
id: action.id,
pluginId: action.pluginId,
localId: action.localId,
...(action.machineId === undefined ? {} : { machineId: action.machineId }),
title: action.title,
run: () => action.run(scopedContext),
};
@@ -81,12 +85,12 @@ export class PluginRegistry {
});
}
private qualifyAction(pluginId: string, action: PluginAction): RegisteredPluginAction {
private qualifyAction(pluginId: string, action: PluginAction, machineId: string | undefined): RegisteredPluginAction {
const id = this.qualify(pluginId, action.id);
return { ...action, id, pluginId, localId: action.id };
return { ...action, id, pluginId, localId: action.id, ...(machineId === undefined ? {} : { machineId }) };
}
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution, machineId: string | undefined): QualifiedWorkspacePanelContribution {
const id = this.qualify(pluginId, panel.id);
const badge = panel.badge;
const visible = panel.visible;
@@ -95,15 +99,26 @@ export class PluginRegistry {
id,
pluginId,
localId: panel.id,
...(visible === undefined ? {} : { visible: (context: WorkspacePanelContext) => visible(workspacePanelContextFor(context, pluginId)) }),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
...(machineId === undefined ? {} : { machineId }),
visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
};
}
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution): QualifiedWorkspaceLabelContribution {
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution, machineId: string | undefined): QualifiedWorkspaceLabelContribution {
const id = this.qualify(pluginId, contribution.id);
return { ...contribution, id, pluginId, localId: contribution.id };
const visible = contribution.visible;
const items = contribution.items;
return {
...contribution,
id,
pluginId,
localId: contribution.id,
...(machineId === undefined ? {} : { machineId }),
visible: (context) => isActiveForMachine(machineId, context.machine.id) && (visible?.(context) ?? true),
items: (context) => isActiveForMachine(machineId, context.machine.id) ? items(context) : [],
};
}
private qualifyTheme(pluginId: string, theme: ThemeContribution): QualifiedThemeContribution {
@@ -163,6 +178,14 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
return context;
}
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string): boolean {
return machineId === undefined || machineId === selectedMachineId;
}
function runtimeContextMachineId(context: PluginRuntimeContext): string {
return context.state.selectedMachine?.id ?? "local";
}
function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): PluginMachine {
const machine = state.selectedMachine;
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
+5
View File
@@ -12,6 +12,8 @@ export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[
export interface PiWebPluginRegistration {
id: PluginId;
plugin: PiWebPlugin;
machineId?: string;
sourcePluginId?: PluginId;
}
export interface PiWebPlugin {
@@ -112,6 +114,7 @@ export interface PluginAction {
export interface QualifiedPluginAction extends AppAction {
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
export interface WorkspacePanelContext {
@@ -159,6 +162,7 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
export interface WorkspaceLabelContext {
@@ -272,4 +276,5 @@ export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContr
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}