From 4bc00102eb7476af254ec3297a8716d8143cf2b0 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 10:11:07 +0200 Subject: [PATCH] feat(plugins): add workspace file helpers to labels --- .changeset/workspace-label-file-context.md | 5 ++ docs/plugins.md | 57 +++++++++++++++++- src/client/src/components/PiWebApp.ts | 43 ++++++++++---- src/client/src/plugins/registry.test.ts | 69 ++++++++++++++++++++-- src/client/src/plugins/registry.ts | 13 +--- src/client/src/plugins/types.ts | 33 +++++++---- src/plugin-api.ts | 33 +++++++---- 7 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 .changeset/workspace-label-file-context.md diff --git a/.changeset/workspace-label-file-context.md b/.changeset/workspace-label-file-context.md new file mode 100644 index 0000000..fc950fe --- /dev/null +++ b/.changeset/workspace-label-file-context.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add workspace file and render helpers to plugin workspace label callbacks so labels can load workspace-scoped metadata without hidden panels. diff --git a/docs/plugins.md b/docs/plugins.md index a23c98d..66570fd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -596,12 +596,18 @@ interface WorkspaceLabelContext { machine: PluginMachine; workspace: Workspace; state?: PluginRuntimeState; + files: { + readFile(path: string): Promise; + }; + host: { + requestRender(): void; + }; } ``` -`machine` and `workspace` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data. +`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks. -Items are sorted by `order` and then id. Return an empty array to render nothing. +Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes. #### Text items @@ -661,7 +667,7 @@ export default { ## Reading workspace files -Workspace panels can read files through the documented `files` helper. PI WEB binds this helper to the panel's machine and workspace, so it works the same for local and federated machines. +Workspace panels and workspace labels can read files through the documented `files` helper. PI WEB binds this helper to the callback's machine and workspace, so it works the same for local and federated machines. ```js workspacePanels: [ @@ -691,6 +697,51 @@ class MyEnvViewer extends HTMLElement { } ``` +Labels should use the same helper through a plugin-owned cache because `items()` itself must return synchronously: + +```js +const envCache = new Map(); + +function envKey(machine, workspace) { + return `${machine.id}:${workspace.id}:docker/development.be-go.local.env`; +} + +function loadEnvLabel(context) { + const key = envKey(context.machine, context.workspace); + const cached = envCache.get(key); + if (cached !== undefined) return cached; + + const pending = { status: "loading", label: undefined }; + envCache.set(key, pending); + context.files.readFile("docker/development.be-go.local.env") + .then((file) => { + pending.status = "ready"; + pending.label = file.content.match(/^DEV_URL=(.+)$/m)?.[1]; + context.host.requestRender(); + }) + .catch(() => { + pending.status = "missing"; + context.host.requestRender(); + }); + return pending; +} + +workspaceLabels: [ + { + id: "dev-url", + items: (context) => { + const cached = loadEnvLabel(context); + return cached.label === undefined ? [] : [{ + type: "link", + text: cached.label, + href: cached.label, + target: "_blank", + }]; + }, + }, +] +``` + The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin. ## Running workspace terminal commands diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index e0e2735..9d25f0c 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -19,7 +19,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { selectedMachineId } from "../controllers/types"; import { RealtimeSocket } from "../sessionSocket"; -import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types"; +import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, 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"; @@ -725,7 +725,7 @@ export class PiWebApp extends LitElement { private renderWorkspacePanel() { const workspace = this.state.selectedWorkspace; const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace); - const workspaceLabelItems = workspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, workspace); + const workspaceLabelItems = workspace === undefined ? [] : this.workspaceLabelItems(workspace); const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined; return html` this.plugins.getWorkspaceLabelItems(this.state, workspace)} + .workspaceLabelItems=${(workspace: Workspace) => this.workspaceLabelItems(workspace)} .refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined} .onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }} .onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }} @@ -901,6 +901,33 @@ export class PiWebApp extends LitElement { } } + private workspaceLabelItems(workspace: Workspace): WorkspaceLabelItem[] { + return this.plugins.getWorkspaceLabelItems(this.createWorkspaceLabelContext(workspace)); + } + + private createWorkspaceLabelContext(workspace: Workspace): WorkspaceLabelContext { + const machine = pluginMachineFromState(this.state); + return { + machine, + workspace, + state: this.state, + files: this.createWorkspaceFiles(workspace, machine.id), + host: this.createWorkspaceHost(), + }; + } + + private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles { + return { + readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId), + }; + } + + private createWorkspaceHost(): WorkspaceHost { + return { + requestRender: () => { this.requestUpdate(); }, + }; + } + private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext { const machine = pluginMachineFromState(this.state); const machineId = machine.id; @@ -910,16 +937,12 @@ export class PiWebApp extends LitElement { machine, workspace, state: this.state, - files: { - readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId), - }, + files: this.createWorkspaceFiles(workspace, machineId), terminal: { open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); }, runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }), }, - host: { - requestRender: () => { this.requestUpdate(); }, - }, + host: this.createWorkspaceHost(), piWebUnstable: { terminalCommandRuns }, fileTree: this.state.fileTree, expandedDirs: this.state.expandedDirs, @@ -1353,7 +1376,7 @@ export class PiWebApp extends LitElement { ${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> - + ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 3faf7a4..bbe7312 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -1,13 +1,13 @@ import { html } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { SessionInfo, Workspace } from "../api"; +import type { FileContentResponse, 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, WorkspacePanelContext } from "./types"; +import type { PluginRuntimeContext, ThemeTokens, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "./types"; function createContext(statePatch: Partial = {}) { const calls: string[] = []; @@ -286,12 +286,45 @@ describe("PluginRegistry", () => { }, }); - expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([ + expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([ { type: "link", text: "web", href: "http://localhost:5173" }, { type: "text", text: "last" }, ]); }); + it("passes workspace label file and host helpers to callbacks", () => { + const registry = new PluginRegistry(); + const workspace = testWorkspace(); + const readFile = vi.fn(() => Promise.resolve(testFileContent("docker/development.be-go.local.env"))); + const requestRender = vi.fn(); + const visible = vi.fn<(context: WorkspaceLabelContext) => boolean>(() => true); + const items = vi.fn<(context: WorkspaceLabelContext) => WorkspaceLabelItem[]>((context) => { + void context.files.readFile("docker/development.be-go.local.env"); + context.host.requestRender(); + return [{ type: "text", text: context.machine.id }]; + }); + const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } }); + + registry.register({ + id: "example", + plugin: { + apiVersion: 1, + name: "Example", + activate: () => ({ + contributions: { + workspaceLabels: [{ id: "env", visible, items }], + }, + }), + }, + }); + + expect(registry.getWorkspaceLabelItems(context)).toEqual([{ type: "text", text: "remote-1" }]); + expect(visible).toHaveBeenCalledWith(context); + expect(items).toHaveBeenCalledWith(context); + expect(readFile).toHaveBeenCalledWith("docker/development.be-go.local.env"); + expect(requestRender).toHaveBeenCalledOnce(); + }); + it("only exposes machine-scoped plugin contributions for their machine", () => { const registry = new PluginRegistry(); const pluginId = machineScopedPluginId("remote-1", "project-tools"); @@ -321,8 +354,8 @@ describe("PluginRegistry", () => { 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.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([]); + expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "remote" }]); expect(registry.getThemes()).toEqual([]); }); @@ -371,7 +404,7 @@ describe("PluginRegistry", () => { const panels = registry.getWorkspacePanels(); expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false); expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true); - expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "gateway" }]); + expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]); }); it("does not activate remote duplicates when the gateway plugin is already registered", () => { @@ -394,6 +427,18 @@ function testWorkspace(patch: Partial = {}): Workspace { return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch }; } +function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial> = {}): WorkspaceLabelContext { + const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn(() => Promise.resolve(testFileContent())) }; + const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn() }; + return { + machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, + workspace, + state: { ...initialAppState(), selectedMachine: testMachine(machineId) }, + files, + host, + }; +} + function createWorkspacePanelContext(machineId: string): WorkspacePanelContext { const workspace = testWorkspace(); return { @@ -425,6 +470,18 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext { }; } +function testFileContent(path = "README.md"): FileContentResponse { + return { + path, + encoding: "utf8", + size: 0, + modifiedAt: "2026-05-20T00:00:00.000Z", + content: "", + truncated: false, + binary: false, + }; +} + 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" }; } diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 30105f2..61589ee 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -1,7 +1,5 @@ import { html, svg } from "lit"; -import type { AppState } from "../appState"; -import type { Workspace } from "../api"; -import type { PiWebPluginRegistration, PluginAction, PluginMachine, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types"; +import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContext, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types"; const idPattern = /^[a-z][a-z0-9.-]*$/u; const localIdPattern = /^[a-z][a-z0-9.-]*$/u; @@ -79,8 +77,7 @@ export class PluginRegistry { return [...this.themePairs].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.name.localeCompare(right.name)); } - getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] { - const context = { machine: pluginMachineFromState(state), state, workspace }; + getWorkspaceLabelItems(context: WorkspaceLabelContext): WorkspaceLabelItem[] { return [...this.workspaceLabels] .sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id)) .flatMap((contribution) => { @@ -197,9 +194,3 @@ function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPlug function runtimeContextMachineId(context: PluginRuntimeContext): string { return context.state.selectedMachine?.id ?? "local"; } - -function pluginMachineFromState(state: Pick): PluginMachine { - const machine = state.selectedMachine; - if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind }; - return { id: "local", name: "local", kind: "local" }; -} diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index bf73183..4290865 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -47,10 +47,26 @@ export interface PluginMachine { kind: Machine["kind"]; } -export interface WorkspacePanelFiles { +export interface WorkspaceFiles { readFile(path: string): Promise; } +export type WorkspacePanelFiles = WorkspaceFiles; + +export interface WorkspaceHost { + requestRender(): void; +} + +export type WorkspacePanelHost = WorkspaceHost; + +export interface WorkspaceContext { + machine: PluginMachine; + workspace: Workspace; + state: AppState; + files: WorkspaceFiles; + host: WorkspaceHost; +} + export type WorkspaceTerminalCommandInput = Omit; export interface WorkspacePanelTerminal { @@ -58,10 +74,6 @@ export interface WorkspacePanelTerminal { runCommand(input: WorkspaceTerminalCommandInput): Promise; } -export interface WorkspacePanelHost { - requestRender(): void; -} - export interface PiWebUnstableRuntimeContext { terminalCommandRuns: TerminalCommandRunsInternalRuntime; openSettings?: (section?: SettingsSection) => void; @@ -117,13 +129,8 @@ export interface QualifiedPluginAction extends AppAction { machineId?: string; } -export interface WorkspacePanelContext { - machine: PluginMachine; - workspace: Workspace; - state: AppState; - files: WorkspacePanelFiles; +export interface WorkspacePanelContext extends WorkspaceContext { terminal: WorkspacePanelTerminal; - host: WorkspacePanelHost; piWebUnstable?: Pick; fileTree: FileTreeEntry[]; expandedDirs: Record; @@ -165,10 +172,12 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr machineId?: string; } -export interface WorkspaceLabelContext { +export interface WorkspaceLabelContext extends WorkspaceContext { machine: PluginMachine; workspace: Workspace; state: AppState; + files: WorkspaceFiles; + host: WorkspaceHost; } export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index afe3752..d7285a5 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -108,10 +108,26 @@ export interface Workspace { isGitWorktree: boolean; } -export interface WorkspacePanelFiles { +export interface WorkspaceFiles { readFile(path: string): Promise; } +export type WorkspacePanelFiles = WorkspaceFiles; + +export interface WorkspaceHost { + requestRender(): void; +} + +export type WorkspacePanelHost = WorkspaceHost; + +export interface WorkspaceContext { + machine: PluginMachine; + workspace: Workspace; + state?: PluginRuntimeState; + files: WorkspaceFiles; + host: WorkspaceHost; +} + export interface WorkspaceTerminalCommandInput { title: string; command: string; @@ -124,17 +140,8 @@ export interface WorkspacePanelTerminal { runCommand(input: WorkspaceTerminalCommandInput): Promise; } -export interface WorkspacePanelHost { - requestRender(): void; -} - -export interface WorkspacePanelContext { - machine: PluginMachine; - workspace: Workspace; - state?: PluginRuntimeState; - files: WorkspacePanelFiles; +export interface WorkspacePanelContext extends WorkspaceContext { terminal: WorkspacePanelTerminal; - host: WorkspacePanelHost; } export type WorkspacePanelIcon = TemplateResult; @@ -149,10 +156,12 @@ export interface WorkspacePanelContribution { render: (context: WorkspacePanelContext) => TemplateResult; } -export interface WorkspaceLabelContext { +export interface WorkspaceLabelContext extends WorkspaceContext { machine: PluginMachine; workspace: Workspace; state?: PluginRuntimeState; + files: WorkspaceFiles; + host: WorkspaceHost; } export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem;