feat: add public plugin workspace capabilities

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 13:00:25 +02:00
parent 93b50e61af
commit e3533ebf1f
28 changed files with 584 additions and 531 deletions
+50 -29
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { configApi, piWebApi, terminalsApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -17,7 +17,7 @@ import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelectio
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import type { 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";
@@ -828,7 +828,8 @@ export class PiWebApp extends LitElement {
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
const workspace = this.state.selectedWorkspace;
if (workspace === undefined) return [];
return this.plugins.getWorkspacePanels().filter((panel) => panel.visible?.({ workspace, state: this.state }) ?? true);
const context = this.createWorkspacePanelContext(workspace);
return this.plugins.getWorkspacePanels().filter((panel) => panel.visible?.(context) ?? true);
}
private workspacePanelEmptyState(): WorkspacePanelEmptyState {
@@ -892,31 +893,45 @@ export class PiWebApp extends LitElement {
}
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
const createContext = (origin: string): WorkspacePanelContext => installWorkspacePanelScope({
workspace,
state: this.state,
piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) },
fileTree: this.state.fileTree,
expandedDirs: this.state.expandedDirs,
selectedFilePath: this.state.selectedFilePath,
selectedFileContent: this.state.selectedFileContent,
fileTreeStale: this.state.fileTreeStale,
gitStatus: this.state.gitStatus,
selectedDiffPath: this.state.selectedDiffPath,
selectedDiff: this.state.selectedDiff,
selectedStagedDiff: this.state.selectedStagedDiff,
gitStale: this.state.gitStale,
activeTerminalCount: this.state.activeTerminalCount,
selectedTerminalId: this.state.selectedTerminalId,
terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id,
openTerminal: (options) => { this.openTerminal(options); },
onRefreshFiles: () => { void this.files.refreshFiles(); },
onExpandDir: (path: string) => { void this.files.expandDir(path); },
onSelectFile: (path: string) => { void this.files.selectFile(path); },
onRefreshGit: () => { void this.git.refreshGit(); },
onSelectDiff: (path: string) => { void this.git.selectDiff(path); },
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); },
}, createContext);
const machine = pluginMachineFromState(this.state);
const machineId = machine.id;
const createContext = (origin: string): WorkspacePanelContext => {
const terminalCommandRuns = this.terminalCommandRunsForOrigin(origin, machineId);
return installWorkspacePanelScope({
machine,
workspace,
state: this.state,
files: {
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
},
terminal: {
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
},
requestRender: () => { this.requestUpdate(); },
piWebUnstable: { terminalCommandRuns },
fileTree: this.state.fileTree,
expandedDirs: this.state.expandedDirs,
selectedFilePath: this.state.selectedFilePath,
selectedFileContent: this.state.selectedFileContent,
fileTreeStale: this.state.fileTreeStale,
gitStatus: this.state.gitStatus,
selectedDiffPath: this.state.selectedDiffPath,
selectedDiff: this.state.selectedDiff,
selectedStagedDiff: this.state.selectedStagedDiff,
gitStale: this.state.gitStale,
activeTerminalCount: this.state.activeTerminalCount,
selectedTerminalId: this.state.selectedTerminalId,
terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id,
openTerminal: (options) => { this.openTerminal(options); },
onRefreshFiles: () => { void this.files.refreshFiles(); },
onExpandDir: (path: string) => { void this.files.expandDir(path); },
onSelectFile: (path: string) => { void this.files.selectFile(path); },
onRefreshGit: () => { void this.git.refreshGit(); },
onSelectDiff: (path: string) => { void this.git.selectDiff(path); },
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); },
}, createContext);
};
return createContext("core");
}
@@ -944,7 +959,7 @@ export class PiWebApp extends LitElement {
private createPluginRuntimeContext(): PluginRuntimeContext {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
piWebInternal: {
piWebUnstable: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); },
},
@@ -1343,6 +1358,12 @@ function createPluginRegistry(): PluginRegistry {
return registry;
}
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" };
}
function machineActivitySubscriptionInputsChanged(previous: AppState, next: AppState): boolean {
return previous.machines !== next.machines
|| previous.machineStatuses !== next.machineStatuses
+1 -1
View File
@@ -85,7 +85,7 @@ export function createCoreActions(): PluginAction[] {
description: "Manage PI WEB configuration and keyboard shortcuts",
shortcut: "mod+,",
group: "Preferences",
run: (context) => { context.piWebInternal?.openSettings?.(); },
run: (context) => { context.piWebUnstable?.openSettings?.(); },
},
{
id: "app.refresh-data",
+2 -2
View File
@@ -86,7 +86,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
`;
}
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.state.selectedMachine?.id ?? "local" });
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id });
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<div class="image-preview">
@@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
loadTerminalPanel();
return html`<terminal-panel .workspace=${context.workspace} .machineId=${context.state.selectedMachine?.id ?? "local"} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
return html`<terminal-panel .workspace=${context.workspace} .machineId=${context.machine.id} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
}
function renderGit(context: WorkspacePanelContext): TemplateResult {
+1 -1
View File
@@ -11,7 +11,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
const context: PluginRuntimeContext = {
state: { ...initialAppState(), ...statePatch },
piWebInternal: {
piWebUnstable: {
terminalCommandRuns: {
runCommand: vi.fn(),
listCommandRuns: vi.fn(),
+10 -2
View File
@@ -1,7 +1,7 @@
import { html, svg } from "lit";
import type { AppState } from "../appState";
import type { Workspace } from "../api";
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
import type { PiWebPluginRegistration, PluginAction, PluginMachine, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
const idPattern = /^[a-z][a-z0-9.-]*$/u;
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
@@ -72,7 +72,7 @@ export class PluginRegistry {
}
getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] {
const context = { state, workspace };
const context = { machine: pluginMachineFromState(state), state, workspace };
return [...this.workspaceLabels]
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
.flatMap((contribution) => {
@@ -89,11 +89,13 @@ export class PluginRegistry {
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
const id = this.qualify(pluginId, panel.id);
const badge = panel.badge;
const visible = panel.visible;
return {
...panel,
id,
pluginId,
localId: panel.id,
...(visible === undefined ? {} : { visible: (context: WorkspacePanelContext) => visible(workspacePanelContextFor(context, pluginId)) }),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
};
@@ -160,3 +162,9 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
workspacePanelScopes.set(context, scope);
return context;
}
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" };
}
+27 -10
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import type { AppAction } from "../actions";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
import type { AppState } from "../appState";
import type { SettingsSection } from "../settingsRoute";
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
@@ -39,7 +39,24 @@ export interface PluginContributions {
themePairs?: ThemePairContribution[];
}
export interface PiWebInternalRuntimeContext {
export interface PluginMachine {
id: string;
name: string;
kind: Machine["kind"];
}
export interface WorkspacePanelFiles {
readFile(path: string): Promise<FileContentResponse>;
}
export type WorkspaceTerminalCommandInput = Omit<RunTerminalCommandInput, "workspace">;
export interface WorkspacePanelTerminal {
open(options?: { terminalId?: string | undefined }): void;
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
}
export interface PiWebUnstableRuntimeContext {
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
openSettings?: (section?: SettingsSection) => void;
}
@@ -53,7 +70,7 @@ export interface TerminalCommandRunsInternalRuntime {
export interface PluginRuntimeContext {
state: AppState;
piWebInternal?: PiWebInternalRuntimeContext;
piWebUnstable?: PiWebUnstableRuntimeContext;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
@@ -93,15 +110,14 @@ export interface QualifiedPluginAction extends AppAction {
localId: LocalContributionId;
}
export interface WorkspacePanelVisibilityContext {
workspace: Workspace;
state: AppState;
}
export interface WorkspacePanelContext {
machine: PluginMachine;
workspace: Workspace;
state: AppState;
piWebInternal?: PiWebInternalRuntimeContext;
files: WorkspacePanelFiles;
terminal: WorkspacePanelTerminal;
requestRender: () => void;
piWebUnstable?: Pick<PiWebUnstableRuntimeContext, "terminalCommandRuns">;
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined;
@@ -131,7 +147,7 @@ export interface WorkspacePanelContribution {
title: string;
icon?: WorkspacePanelIcon;
order?: number;
visible?: (context: WorkspacePanelVisibilityContext) => boolean;
visible?: (context: WorkspacePanelContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
render: (context: WorkspacePanelContext) => TemplateResult;
}
@@ -143,6 +159,7 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr
}
export interface WorkspaceLabelContext {
machine: PluginMachine;
workspace: Workspace;
state: AppState;
}