feat(plugins): add workspace file helpers to labels

This commit is contained in:
Federico Jaramillo Martinez
2026-06-05 10:11:07 +02:00
parent 0e64b812dd
commit 4bc00102eb
7 changed files with 199 additions and 54 deletions
@@ -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.
+54 -3
View File
@@ -596,12 +596,18 @@ interface WorkspaceLabelContext {
machine: PluginMachine;
workspace: Workspace;
state?: PluginRuntimeState;
files: {
readFile(path: string): Promise<FileContentResponse>;
};
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
+33 -10
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 { 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`
<workspace-panel
@@ -802,7 +802,7 @@ export class PiWebApp extends LitElement {
.projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")}
.workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")}
.sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")}
.workspaceLabelItems=${(workspace: Workspace) => 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`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.workspaceLabelItems(state.selectedWorkspace)}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
+63 -6
View File
@@ -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<AppState> = {}) {
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<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent("docker/development.be-go.local.env")));
const requestRender = vi.fn<WorkspaceHost["requestRender"]>();
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> = {}): 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<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())) };
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
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" };
}
+2 -11
View File
@@ -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<AppState, "selectedMachine">): 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" };
}
+21 -12
View File
@@ -47,10 +47,26 @@ export interface PluginMachine {
kind: Machine["kind"];
}
export interface WorkspacePanelFiles {
export interface WorkspaceFiles {
readFile(path: string): Promise<FileContentResponse>;
}
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<RunTerminalCommandInput, "workspace">;
export interface WorkspacePanelTerminal {
@@ -58,10 +74,6 @@ export interface WorkspacePanelTerminal {
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
}
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<PiWebUnstableRuntimeContext, "terminalCommandRuns">;
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
@@ -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;
+21 -12
View File
@@ -108,10 +108,26 @@ export interface Workspace {
isGitWorktree: boolean;
}
export interface WorkspacePanelFiles {
export interface WorkspaceFiles {
readFile(path: string): Promise<FileContentResponse>;
}
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<TerminalCommandRunHandle>;
}
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;