Archived
feat: load machine-scoped remote plugins
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user