diff --git a/.changeset/plugin-public-context-helpers.md b/.changeset/plugin-public-context-helpers.md
new file mode 100644
index 0000000..ddaeeb4
--- /dev/null
+++ b/.changeset/plugin-public-context-helpers.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add documented plugin context helpers for machine-scoped workspace files and terminal commands, generate plugin API declarations from source, and move bundled plugins away from direct PI WEB API calls.
diff --git a/README.md b/README.md
index b47dd45..1a91c11 100644
--- a/README.md
+++ b/README.md
@@ -110,7 +110,7 @@ Remote model-provider credentials and OAuth state stay on the target machine. AP
## Plugins
-PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata. They do not run in the session daemon and are not sandboxed.
+PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed.
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
diff --git a/docs/plugins.html b/docs/plugins.html
index f618e62..b92da42 100644
--- a/docs/plugins.html
+++ b/docs/plugins.html
@@ -84,10 +84,11 @@
Workspace tools and panels next to Files, Git, and Terminal.
Workspace labels in the workspace list, header, and status bar.
Static assets served from the plugin folder.
-
Browser-side integrations using PI WEB HTTP/WebSocket APIs.
+
Browser-side integrations using documented PI WEB plugin context helpers.
Plugins cannot extend the session daemon or add server-side hooks. They run in the browser UI only.
+ PI WEB's internal API routes are not plugin API; use documented context helpers instead.
The plugin API is intentionally limited and actively developed. Feedback is appreciated: if an extension
diff --git a/docs/plugins.md b/docs/plugins.md
index 97d5fe8..91a4524 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -7,7 +7,8 @@ Plugins can currently:
- add action-palette commands;
- add workspace tools/panels next to Files, Git, and Terminal;
- add compact workspace-label items in the workspace list, panel header, and status bar;
-- call browser APIs and PI WEB HTTP/WebSocket APIs available to the current browser session;
+- call browser APIs and documented PI WEB plugin context helpers;
+- read workspace files and start workspace terminal commands through documented helpers;
- serve their own static assets from the plugin directory.
They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed.
@@ -17,11 +18,12 @@ They do **not** run in the session daemon, do not get a server-side hook API, an
Plugins run as JavaScript in the browser app. Treat them as trusted code:
- they can call browser APIs;
-- they can `fetch()` PI WEB API endpoints using the current browser access;
-- they can read workspace files through PI WEB's file endpoints if the UI can read them;
+- they can read workspace files and start terminal commands through documented plugin helpers;
- they can render arbitrary Lit templates/custom elements in plugin contribution areas;
- they should not be installed from untrusted sources.
+PI WEB's `/api/...` HTTP and WebSocket endpoints are internal implementation details. Plugin code should not fetch PI WEB API endpoints directly; use the documented context helpers instead.
+
## What to ask AI to build
Humans should not need to hand-code plugins. Give an AI agent a concrete UI goal and ask it to create or modify a local plugin.
@@ -405,6 +407,7 @@ interface PluginRuntimeContext {
state: {
selectedWorkspace?: Workspace;
selectedSession?: unknown;
+ piWebStatus?: PiWebStatusResponse;
};
openActionPalette: () => void;
focusPrompt: () => void;
@@ -424,12 +427,12 @@ interface PluginRuntimeContext {
Notes:
- `state` is a snapshot of current UI state when actions are built.
-- Only `state.selectedWorkspace` and `state.selectedSession` are documented as stable for plugin authors.
+- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
- Other `state` fields may exist at runtime, but they are PI WEB internals and can change quickly.
- `enabled` is evaluated when the action palette asks for actions.
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
-- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. PI WEB may attach `piWebInternal` fields at runtime for first-party dogfooding; plugins should not depend on those fields because they can change or disappear without notice.
+- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Unstable runtime fields are intentionally omitted from these types; if a plugin author chooses to depend on them, they must explicitly import unstable types from `@jmfederico/pi-web/plugin-api/unstable` and type-assert the context in their own code.
#### Keyboard shortcuts
@@ -477,24 +480,45 @@ interface WorkspacePanelContribution {
title: string;
icon?: TemplateResult;
order?: number;
- visible?: (context: { workspace: Workspace }) => boolean;
+ visible?: (context: WorkspacePanelContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
render: (context: WorkspacePanelContext) => TemplateResult;
}
interface WorkspacePanelContext {
+ machine: PluginMachine;
workspace: Workspace;
+ state?: PluginRuntimeState;
+ files: {
+ readFile(path: string): Promise;
+ };
+ terminal: {
+ open(options?: { terminalId?: string }): void;
+ runCommand(input: {
+ title: string;
+ command: string;
+ metadata?: Record;
+ open?: boolean;
+ }): Promise;
+ };
+ requestRender: () => void;
openTerminal: (options?: { terminalId?: string }) => void;
}
```
`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide.
-`workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. If a panel needs file, git, terminal, or session data beyond the helpers documented here, prefer explicit `fetch()` calls and keep them isolated.
+`machine`, `workspace`, `files`, `terminal`, `requestRender()`, and `openTerminal()` are documented as stable for panel callbacks. `terminal.open()` is equivalent to `openTerminal()`; new plugins should prefer `terminal.open()` so terminal-related helpers live under one capability.
-Useful workspace shape:
+Useful workspace and machine shapes:
```ts
+interface PluginMachine {
+ id: string;
+ name: string;
+ kind: "local" | "remote";
+}
+
interface Workspace {
id: string;
projectId: string;
@@ -507,6 +531,8 @@ interface Workspace {
}
```
+`machine.id` is included in panel contexts so plugins can keep caches machine-scoped. Do not infer the selected machine from global browser state.
+
Use existing classes such as `toolbar`, `viewer`, `empty`, and `muted` for panel content when possible. Do not assume a panel owns the whole page; keep layout contained.
### Workspace labels
@@ -543,11 +569,13 @@ interface WorkspaceLabelContribution {
}
interface WorkspaceLabelContext {
+ machine: PluginMachine;
workspace: Workspace;
+ state?: PluginRuntimeState;
}
```
-Only `workspace` is documented as stable for label callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly.
+`machine` and `workspace` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data.
Items are sorted by `order` and then id. Return an empty array to render nothing.
@@ -609,55 +637,71 @@ export default {
## Reading workspace files
-Plugins can use existing PI WEB endpoints. For example, to read a file in a workspace:
+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.
```js
-async function readWorkspaceFile(workspace, path) {
- const url =
- `/api/projects/${encodeURIComponent(workspace.projectId)}` +
- `/workspaces/${encodeURIComponent(workspace.id)}` +
- `/file?path=${encodeURIComponent(path)}`;
+workspacePanels: [
+ {
+ id: "workspace.env",
+ title: "Env",
+ render: ({ files, requestRender }) => html`
+
+ `,
+ },
+]
- const response = await fetch(url, { cache: "no-store" });
- if (!response.ok) throw new Error(`Failed to read ${path}: ${response.status}`);
- return await response.json();
+class MyEnvViewer extends HTMLElement {
+ set files(value) {
+ this._files = value;
+ void this.load();
+ }
+
+ async load() {
+ try {
+ const file = await this._files.readFile(".env.example");
+ this.textContent = file.binary ? "Binary file" : file.content;
+ } catch (error) {
+ this.textContent = error instanceof Error ? error.message : String(error);
+ }
+ }
}
```
-The file response includes fields such as `path`, `content`, `truncated`, and `binary`, but endpoint response shapes are private PI WEB implementation details for now and can change between releases.
+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.
-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
-## Other useful PI WEB APIs
+Workspace panels can start terminal commands through the documented `terminal` helper. Commands run in the current workspace on the panel's machine.
-Plugins may call any endpoint available to the browser, but these HTTP endpoints are considered private PI WEB implementation APIs for now. They can change quickly between releases. Prefer plugin runtime context helpers when they cover the interaction, and keep any direct HTTP usage small and isolated.
-
-Common read endpoints:
-
-```text
-GET /api/projects
-GET /api/projects/:projectId/workspaces
-GET /api/projects/:projectId/workspaces/:workspaceId/tree?path=
-GET /api/projects/:projectId/workspaces/:workspaceId/file?path=
-GET /api/projects/:projectId/workspaces/:workspaceId/git/status
-GET /api/projects/:projectId/workspaces/:workspaceId/git/diff?path=&staged=true|false
-GET /api/sessions?cwd=
-GET /api/sessions/:sessionId/status
-GET /api/sessions/:sessionId/messages?before=&limit=
+```js
+render: ({ terminal }) => html`
+
+`
```
-Common write/action endpoints:
+Review command strings carefully. They are trusted shell commands executed in the workspace terminal.
-```text
-POST /api/sessions { "cwd": "/path/to/workspace" }
-POST /api/sessions/:id/prompt { "text": "...", "streamingBehavior": "steer" | "followUp" }
-POST /api/sessions/:id/shell { "text": "..." }
-POST /api/sessions/:id/stop
-POST /api/sessions/:id/archive
-POST /api/sessions/:id/restore
+## Internal PI WEB APIs and explicit unstable opt-in
+
+PI WEB's `/api/...` HTTP and WebSocket routes are private implementation details. Plugin code should not fetch PI WEB API endpoints directly because those URLs, response shapes, and machine-federation routing rules can change.
+
+If a plugin author deliberately chooses to depend on an unstable runtime field while a public helper is still being designed, make that decision explicit in code with a type-only unstable import and a local type assertion:
+
+```ts
+import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
+import type { UnstableWorkspacePanelContext } from "@jmfederico/pi-web/plugin-api/unstable";
+
+function unstableContext(context: WorkspacePanelContext) {
+ return context as WorkspacePanelContext & UnstableWorkspacePanelContext;
+}
```
-Prefer runtime context helpers (`startSession`, `stopActiveWork`, `refreshFiles`, `refreshGit`, etc.) when they cover the interaction. Use direct HTTP calls only for plugin-specific data or behavior, and expect to update them as PI WEB evolves.
+Unstable APIs are not covered by the v1 compatibility promise. Prefer documented helpers whenever they exist.
## Async data and caching
@@ -666,7 +710,7 @@ PI WEB does not provide a plugin cache/invalidation framework. Keep host callbac
- simple contributions should be synchronous and cheap;
- expensive or async work should live inside the plugin;
- custom elements in `type: "render"` label items or panels are a good place to own async loading;
-- dedupe fetches and avoid unbounded polling;
+- dedupe async reads/commands and avoid unbounded polling;
- clean up intervals/event listeners in custom elements' `disconnectedCallback()`.
## Agent implementation checklist
@@ -684,8 +728,8 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis
9. Add workspace panels for larger workspace UI.
10. Add workspace labels for compact inline metadata.
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
-12. Use stable context fields first; only `workspace`, `state.selectedWorkspace`, and `state.selectedSession` are documented as stable.
-13. Use `fetch()` against PI WEB APIs only for plugin-specific behavior not provided by runtime context helpers, and isolate those calls because HTTP endpoints are private for now.
+12. Use documented context helpers first: `files`, `terminal`, `requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
+13. Do not fetch PI WEB `/api/...` endpoints directly. If an unstable runtime field is intentionally required, import the type from `@jmfederico/pi-web/plugin-api/unstable` and type-assert locally.
14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
diff --git a/package.json b/package.json
index 0327881..93a8752 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"extensions",
"docs/plugins.md",
"docs/assets",
- "plugin-api.d.ts"
+ "plugin-api.d.ts",
+ "plugin-api/unstable.d.ts"
],
"scripts": {
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'",
@@ -27,7 +28,8 @@
"dev:server": "npm run dev:web",
"dev:client": "vite --host 0.0.0.0",
"dev:plugins": "node scripts/build-plugins.mjs --watch",
- "build": "tsc -p tsconfig.build.json && npm run build:plugins && vite build",
+ "build": "tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build",
+ "build:plugin-api": "tsc -p tsconfig.plugin-api.json",
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
diff --git a/pi-web-plugins/info/pi-web-plugin.ts b/pi-web-plugins/info/pi-web-plugin.ts
index a04be40..9a5cdfd 100644
--- a/pi-web-plugins/info/pi-web-plugin.ts
+++ b/pi-web-plugins/info/pi-web-plugin.ts
@@ -1,4 +1,4 @@
-import type { PiWebPlugin } from "../../src/client/src/plugins/types";
+import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
const plugin: PiWebPlugin = {
apiVersion: 1,
diff --git a/pi-web-plugins/pluginPublicApi.test.ts b/pi-web-plugins/pluginPublicApi.test.ts
new file mode 100644
index 0000000..64b053f
--- /dev/null
+++ b/pi-web-plugins/pluginPublicApi.test.ts
@@ -0,0 +1,38 @@
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const pluginRoot = "pi-web-plugins";
+const forbiddenPatterns = [
+ { pattern: /\bfetch\s*\(/u, message: "direct browser fetch" },
+ { pattern: /["'`][^"'`]*\/api\//u, message: "direct PI WEB /api URL" },
+ { pattern: /piWebInternal/u, message: "legacy internal plugin context" },
+ { pattern: /(?:\.\.\/)+src\//u, message: "imports from PI WEB source internals" },
+];
+
+describe("bundled PI WEB plugins", () => {
+ it("use public plugin APIs instead of direct PI WEB internals", async () => {
+ const violations: string[] = [];
+ for (const file of await pluginSourceFiles(pluginRoot)) {
+ const content = await readFile(file, "utf8");
+ for (const { pattern, message } of forbiddenPatterns) {
+ if (pattern.test(content)) violations.push(`${file}: ${message}`);
+ }
+ if (content.includes("piWebUnstable") && !content.includes("@jmfederico/pi-web/plugin-api/unstable")) {
+ violations.push(`${file}: piWebUnstable use without explicit unstable type import`);
+ }
+ }
+
+ expect(violations).toEqual([]);
+ });
+});
+
+async function pluginSourceFiles(root: string): Promise {
+ const files: string[] = [];
+ for (const entry of await readdir(root, { withFileTypes: true })) {
+ const path = join(root, entry.name);
+ if (entry.isDirectory()) files.push(...await pluginSourceFiles(path));
+ else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) files.push(path);
+ }
+ return files;
+}
diff --git a/pi-web-plugins/updates/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts
index fda5c17..4a11efc 100644
--- a/pi-web-plugins/updates/pi-web-plugin.ts
+++ b/pi-web-plugins/updates/pi-web-plugin.ts
@@ -1,17 +1,15 @@
import type { TemplateResult } from "lit";
-import type { AppState } from "../../src/client/src/appState";
-import type { HtmlTemplateTag, PiWebPlugin } from "../../src/client/src/plugins/types";
-import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse } from "../../src/shared/apiTypes";
+import type { HtmlTemplateTag, PiWebComponentStatus, PiWebInstallationInfo, PiWebPlugin, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
-function messagesFor(state: AppState): PiWebStatusMessage[] {
- return state.piWebStatus?.messages ?? [];
+function messagesFor(state: PluginRuntimeState | undefined): PiWebStatusMessage[] {
+ return state?.piWebStatus?.messages ?? [];
}
-function statusFor(state: AppState): PiWebStatusResponse | undefined {
- return state.piWebStatus;
+function statusFor(state: PluginRuntimeState | undefined): PiWebStatusResponse | undefined {
+ return state?.piWebStatus;
}
-function messageCount(state: AppState): number {
+function messageCount(state: PluginRuntimeState | undefined): number {
return messagesFor(state).length;
}
@@ -19,7 +17,7 @@ function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | unde
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
}
-function shouldShowUpdatesPanel(state: AppState): boolean {
+function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean {
const status = statusFor(state);
if (messageCount(state) > 0) return true;
if (status === undefined) return false;
@@ -87,7 +85,7 @@ function renderCommands(html: HtmlTemplateTag, status: PiWebStatusResponse): Tem
`;
}
-function renderUpdatesPanel(html: HtmlTemplateTag, state: AppState): TemplateResult {
+function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | undefined): TemplateResult {
const status = statusFor(state);
if (status === undefined) {
return html`
diff --git a/pi-web-plugins/workspace-tasks/pi-web-plugin.ts b/pi-web-plugins/workspace-tasks/pi-web-plugin.ts
index 6f5a031..8554c02 100644
--- a/pi-web-plugins/workspace-tasks/pi-web-plugin.ts
+++ b/pi-web-plugins/workspace-tasks/pi-web-plugin.ts
@@ -1,7 +1,6 @@
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH } from "./config.js";
import { defineTasksPanelElement, tasksPanelBadge } from "./tasksPanelElement.js";
-import { terminalCommandRunsFromContext } from "./piWebInternal.js";
const plugin: PiWebPlugin = {
apiVersion: 1,
@@ -39,8 +38,8 @@ const plugin: PiWebPlugin = {
`,
order: 40,
- badge: ({ workspace }) => tasksPanelBadge(workspace),
- render: (context) => html``,
+ badge: (context) => tasksPanelBadge(context),
+ render: (context) => html``,
},
],
},
diff --git a/pi-web-plugins/workspace-tasks/piWebInternal.ts b/pi-web-plugins/workspace-tasks/piWebInternal.ts
deleted file mode 100644
index 6bc09ec..0000000
--- a/pi-web-plugins/workspace-tasks/piWebInternal.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import type { Workspace } from "@jmfederico/pi-web/plugin-api";
-
-export interface InternalRunTerminalCommandInput {
- workspace: Workspace;
- title: string;
- command: string;
- metadata?: Record;
- open?: boolean;
-}
-
-export interface InternalTerminalCommandRun {
- id: string;
- origin: string;
- projectId: string;
- workspaceId: string;
- terminalId: string;
- title: string;
- command: string;
- status: "queued" | "running" | "succeeded" | "failed";
- exitCode?: number;
- createdAt: string;
- startedAt?: string;
- completedAt?: string;
- metadata: Record;
-}
-
-export interface InternalTerminalCommandRunHandle {
- run: InternalTerminalCommandRun;
- completed: Promise;
-}
-
-export interface InternalTerminalCommandRunsRuntime {
- runCommand(input: InternalRunTerminalCommandInput): Promise;
- open(options?: { terminalId?: string | undefined }): void;
-}
-
-export function terminalCommandRunsFromContext(context: unknown): InternalTerminalCommandRunsRuntime | undefined {
- if (!isRecord(context)) return undefined;
- const internal = context["piWebInternal"];
- if (!isRecord(internal)) return undefined;
- const terminalCommandRuns = internal["terminalCommandRuns"];
- if (!isRecord(terminalCommandRuns)) return undefined;
- const runCommand = terminalCommandRuns["runCommand"];
- const open = terminalCommandRuns["open"];
- if (!isRunCommand(runCommand) || !isOpen(open)) return undefined;
- return {
- runCommand: (input) => runCommand(input),
- open: (options) => { open(options); },
- };
-}
-
-function isRunCommand(value: unknown): value is InternalTerminalCommandRunsRuntime["runCommand"] {
- return typeof value === "function";
-}
-
-function isOpen(value: unknown): value is InternalTerminalCommandRunsRuntime["open"] {
- return typeof value === "function";
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null;
-}
diff --git a/pi-web-plugins/workspace-tasks/piWebPrivateUi.ts b/pi-web-plugins/workspace-tasks/piWebPrivateUi.ts
deleted file mode 100644
index c04181e..0000000
--- a/pi-web-plugins/workspace-tasks/piWebPrivateUi.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-interface Updatable {
- requestUpdate: () => void;
-}
-
-export function requestPiWebRender(): void {
- const app = document.querySelector("pi-web-app");
- if (isUpdatable(app)) app.requestUpdate();
-}
-
-function isUpdatable(value: unknown): value is Updatable {
- return isRecord(value) && typeof value["requestUpdate"] === "function";
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null;
-}
diff --git a/pi-web-plugins/workspace-tasks/taskRunner.test.ts b/pi-web-plugins/workspace-tasks/taskRunner.test.ts
index 74fd0b8..340d38e 100644
--- a/pi-web-plugins/workspace-tasks/taskRunner.test.ts
+++ b/pi-web-plugins/workspace-tasks/taskRunner.test.ts
@@ -1,24 +1,13 @@
import { describe, expect, it, vi } from "vitest";
-import type { Workspace } from "@jmfederico/pi-web/plugin-api";
+import type { TerminalCommandRun, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
import { runWorkspaceTaskInTerminal } from "./taskRunner";
import type { WorkspaceTask } from "./config";
-import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal";
-const workspace: Workspace = {
- id: "workspace 1",
- projectId: "project/1",
- path: "/repo",
- label: "repo",
- isMain: false,
- isGitRepo: true,
- isGitWorktree: true,
-};
-
-const run: InternalTerminalCommandRun = {
+const run: TerminalCommandRun = {
id: "run1",
origin: "workspace-tasks",
- projectId: workspace.projectId,
- workspaceId: workspace.id,
+ projectId: "project/1",
+ workspaceId: "workspace 1",
terminalId: "term1",
title: "Build",
command: "npm run build",
@@ -28,20 +17,19 @@ const run: InternalTerminalCommandRun = {
};
describe("task runner", () => {
- it("starts workspace tasks through the internal terminal command-run helper", async () => {
+ it("starts workspace tasks through the public workspace terminal helper", async () => {
const task: WorkspaceTask = { id: "build", title: "Build", command: "npm run build", confirm: false };
- const runCommand = vi.fn(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
- const terminal: InternalTerminalCommandRunsRuntime = {
+ const runCommand = vi.fn(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
+ const terminal: WorkspacePanelTerminal = {
runCommand,
open: vi.fn(),
};
- const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
+ const handle = await runWorkspaceTaskInTerminal(terminal, task);
expect(handle.run).toEqual(run);
await expect(handle.completed).resolves.toEqual(run);
expect(runCommand).toHaveBeenCalledWith({
- workspace,
title: "Build",
command: "npm run build",
open: true,
diff --git a/pi-web-plugins/workspace-tasks/taskRunner.ts b/pi-web-plugins/workspace-tasks/taskRunner.ts
index 32f7a17..78159f0 100644
--- a/pi-web-plugins/workspace-tasks/taskRunner.ts
+++ b/pi-web-plugins/workspace-tasks/taskRunner.ts
@@ -1,10 +1,8 @@
-import type { Workspace } from "@jmfederico/pi-web/plugin-api";
+import type { WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
import type { WorkspaceTask } from "./config.js";
-import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
-export function runWorkspaceTaskInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, task: WorkspaceTask): ReturnType {
+export function runWorkspaceTaskInTerminal(terminal: WorkspacePanelTerminal, task: WorkspaceTask): ReturnType {
return terminal.runCommand({
- workspace,
title: task.title,
command: task.command,
open: true,
diff --git a/pi-web-plugins/workspace-tasks/tasksPanelElement.ts b/pi-web-plugins/workspace-tasks/tasksPanelElement.ts
index d698328..ceefc75 100644
--- a/pi-web-plugins/workspace-tasks/tasksPanelElement.ts
+++ b/pi-web-plugins/workspace-tasks/tasksPanelElement.ts
@@ -1,14 +1,10 @@
-import type { Workspace } from "@jmfederico/pi-web/plugin-api";
+import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH, type WorkspaceTask } from "./config.js";
import { runWorkspaceTaskInTerminal } from "./taskRunner.js";
-import { requestPiWebRender } from "./piWebPrivateUi.js";
-import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
import { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage, type WorkspaceTasksConfigLoadResult } from "./workspaceTasksClient.js";
export const tasksPanelTagName = "pi-web-workspace-tasks-panel";
-export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void;
-
const configChangedEvent = "pi-web-workspace-tasks-config-changed";
type ConfigState =
@@ -27,17 +23,15 @@ export function defineTasksPanelElement(): void {
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
}
-export function tasksPanelBadge(workspace: Workspace): string | number | undefined {
- const state = getCachedWorkspaceConfig(workspace);
+export function tasksPanelBadge(context: WorkspacePanelContext): string | number | undefined {
+ const state = getCachedWorkspaceConfig(context);
if (state?.kind === "unavailable") return "!";
if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length;
return undefined;
}
class PiWebTasksPanel extends HTMLElement {
- private workspaceValue: Workspace | undefined;
- private openTerminalValue: OpenTerminal | undefined;
- private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
+ private contextValue: WorkspacePanelContext | undefined;
private runningTaskId: string | undefined;
private status: TaskStatus | undefined;
private readonly root: ShadowRoot;
@@ -50,10 +44,10 @@ class PiWebTasksPanel extends HTMLElement {
this.root = this.attachShadow({ mode: "open" });
}
- set workspace(value: Workspace | undefined) {
- const previousKey = this.workspaceValue === undefined ? undefined : cacheKeyForWorkspace(this.workspaceValue);
- const nextKey = value === undefined ? undefined : cacheKeyForWorkspace(value);
- this.workspaceValue = value;
+ set context(value: WorkspacePanelContext | undefined) {
+ const previousKey = this.contextValue === undefined ? undefined : cacheKeyForContext(this.contextValue);
+ const nextKey = value === undefined ? undefined : cacheKeyForContext(value);
+ this.contextValue = value;
// Parent app updates should not rebuild this shadow DOM for the same workspace:
// doing so resets the mobile scroll position and can replace buttons mid-click.
if (previousKey === nextKey) return;
@@ -62,14 +56,6 @@ class PiWebTasksPanel extends HTMLElement {
this.render();
}
- set openTerminal(value: OpenTerminal | undefined) {
- this.openTerminalValue = value;
- }
-
- set terminalCommandRuns(value: InternalTerminalCommandRunsRuntime | undefined) {
- this.terminalCommandRunsValue = value;
- }
-
connectedCallback(): void {
window.addEventListener(configChangedEvent, this.onConfigChanged);
this.render();
@@ -80,13 +66,13 @@ class PiWebTasksPanel extends HTMLElement {
}
private render(): void {
- const workspace = this.workspaceValue;
- if (workspace === undefined) {
+ const context = this.contextValue;
+ if (context === undefined) {
this.root.innerHTML = `${taskStyles()}Select a workspace.`;
return;
}
- const state = getOrLoadWorkspaceConfig(workspace);
+ const state = getOrLoadWorkspaceConfig(context);
this.root.innerHTML = `
${taskStyles()}
@@ -103,12 +89,12 @@ class PiWebTasksPanel extends HTMLElement {
`;
this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
- void this.refreshConfig(workspace);
+ void this.refreshConfig(context);
});
for (const button of this.root.querySelectorAll("button[data-task-id]")) {
button.addEventListener("click", () => {
- void this.dispatchTaskById(workspace, button.getAttribute("data-task-id"));
+ void this.dispatchTaskById(context, button.getAttribute("data-task-id"));
});
}
@@ -117,19 +103,19 @@ class PiWebTasksPanel extends HTMLElement {
});
}
- private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise {
- if (!this.isCurrentWorkspace(workspace)) return Promise.resolve();
- const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), taskId);
+ private dispatchTaskById(context: WorkspacePanelContext, taskId: string | null): Promise {
+ if (!this.isCurrentContext(context)) return Promise.resolve();
+ const task = taskFromConfigState(getCachedWorkspaceConfig(context), taskId);
if (task === undefined) {
this.status = { kind: "error", message: "That task is no longer available. Click Refresh, then try again." };
this.render();
return Promise.resolve();
}
- return this.dispatchTask(workspace, task);
+ return this.dispatchTask(context, task);
}
- private isCurrentWorkspace(workspace: Workspace): boolean {
- return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace);
+ private isCurrentContext(context: WorkspacePanelContext): boolean {
+ return this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
}
private renderConfigState(state: ConfigState): string {
@@ -150,20 +136,20 @@ class PiWebTasksPanel extends HTMLElement {
return `