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
+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;