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 `
    ${escapeHtml(this.status.message)}${detail}
    `; } - private async refreshConfig(workspace: Workspace): Promise { + private async refreshConfig(context: WorkspacePanelContext): Promise { this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}…` }; - configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" }); + configCache.set(cacheKeyForContext(context), { kind: "loading" }); this.render(); - const state = await refreshWorkspaceConfig(workspace); - if (!this.isCurrentWorkspace(workspace)) return; + const state = await refreshWorkspaceConfig(context); + if (!this.isCurrentContext(context)) return; this.status = state.kind === "loaded" ? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` } : undefined; this.render(); } - private async dispatchTask(workspace: Workspace, task: WorkspaceTask): Promise { + private async dispatchTask(context: WorkspacePanelContext, task: WorkspaceTask): Promise { if (this.runningTaskId !== undefined) { this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." }; this.render(); @@ -175,20 +161,13 @@ class PiWebTasksPanel extends HTMLElement { return; } - const terminal = this.terminalCommandRunsValue; - if (terminal === undefined) { - this.status = { kind: "error", message: "This PI WEB version does not provide terminal command helpers to plugins." }; - this.render(); - return; - } - this.runningTaskId = task.id; this.status = { kind: "info", message: `Starting ${task.title}…` }; this.render(); try { - const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task); - if (!this.isCurrentWorkspace(workspace)) return; + const handle = await runWorkspaceTaskInTerminal(context.terminal, task); + if (!this.isCurrentContext(context)) return; this.status = { kind: "success", message: `Started terminal command “${handle.run.title}”.`, @@ -197,7 +176,7 @@ class PiWebTasksPanel extends HTMLElement { this.runningTaskId = undefined; this.render(); } catch (error) { - if (!this.isCurrentWorkspace(workspace)) return; + if (!this.isCurrentContext(context)) return; this.runningTaskId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.render(); @@ -205,50 +184,47 @@ class PiWebTasksPanel extends HTMLElement { } private openWorkspaceTerminal(terminalId?: string): void { - if (this.terminalCommandRunsValue !== undefined) { - this.terminalCommandRunsValue.open(terminalId === undefined ? undefined : { terminalId }); - return; - } - if (this.openTerminalValue === undefined) { - this.status = { kind: "error", message: "This PI WEB version does not provide terminal navigation to plugins." }; + const context = this.contextValue; + if (context === undefined) { + this.status = { kind: "error", message: "Select a workspace before opening a terminal." }; this.render(); return; } - if (terminalId === undefined) this.openTerminalValue(); - else this.openTerminalValue({ terminalId }); + if (terminalId === undefined) context.terminal.open(); + else context.terminal.open({ terminalId }); } } -function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined { - return configCache.get(cacheKeyForWorkspace(workspace)); +function getCachedWorkspaceConfig(context: WorkspacePanelContext): ConfigState | undefined { + return configCache.get(cacheKeyForContext(context)); } -function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState { - const cached = getCachedWorkspaceConfig(workspace); +function getOrLoadWorkspaceConfig(context: WorkspacePanelContext): ConfigState { + const cached = getCachedWorkspaceConfig(context); if (cached !== undefined) return cached; const loading: ConfigState = { kind: "loading" }; - configCache.set(cacheKeyForWorkspace(workspace), loading); - void refreshWorkspaceConfig(workspace); + configCache.set(cacheKeyForContext(context), loading); + void refreshWorkspaceConfig(context); return loading; } -async function refreshWorkspaceConfig(workspace: Workspace): Promise { - const key = cacheKeyForWorkspace(workspace); - const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({ +async function refreshWorkspaceConfig(context: WorkspacePanelContext): Promise { + const key = cacheKeyForContext(context); + const state = await loadWorkspaceTasksConfig(context.files).catch((error: unknown): ConfigState => ({ kind: "unavailable", message: tasksConfigUnavailableMessage, hint: tasksConfigRefreshHint, detail: error instanceof Error ? error.message : String(error), })); configCache.set(key, state); - requestPiWebRender(); + context.requestRender(); window.dispatchEvent(new Event(configChangedEvent)); return state; } -function cacheKeyForWorkspace(workspace: Workspace): string { - return `${workspace.projectId}:${workspace.id}`; +function cacheKeyForContext(context: WorkspacePanelContext): string { + return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`; } function renderMissingState(state: Extract): string { diff --git a/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts b/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts index e9cd758..6b54b9b 100644 --- a/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts +++ b/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts @@ -1,31 +1,24 @@ -import { describe, expect, it } from "vitest"; -import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { describe, expect, it, vi } from "vitest"; import { TASKS_CONFIG_PATH } from "./config"; -import { loadWorkspaceTasksConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceTasksClient"; - -const workspace: Workspace = { - id: "workspace 1", - projectId: "project/1", - path: "/repo", - label: "repo", - isMain: false, - isGitRepo: true, - isGitWorktree: true, -}; +import { loadWorkspaceTasksConfig, type WorkspaceTasksFileReader } from "./workspaceTasksClient"; describe("workspace tasks client", () => { - it("builds the private workspace file URL", () => { - expect(workspaceFileUrl(workspace, TASKS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Ftasks.json"); + it("loads the configured path through the public workspace file helper", async () => { + const readFile = vi.fn(() => Promise.resolve({ content: JSON.stringify({ version: 1, tasks: [] }), truncated: false, binary: false })); + + await loadWorkspaceTasksConfig({ readFile }); + + expect(readFile).toHaveBeenCalledWith(TASKS_CONFIG_PATH); }); - it("loads and parses a valid tasks config", async () => { - const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + it("loads and parses a valid tasks config through the public workspace file helper", async () => { + const files = reader({ content: JSON.stringify({ version: 1, tasks: [{ id: "build", title: "Build", command: "npm run build" }] }), truncated: false, binary: false, - })); + }); - await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + await expect(loadWorkspaceTasksConfig(files)).resolves.toEqual({ kind: "loaded", path: TASKS_CONFIG_PATH, config: { @@ -36,49 +29,40 @@ describe("workspace tasks client", () => { }); it("treats a missing optional tasks config as unconfigured", async () => { - const fetcher: FetchLike = () => Promise.resolve(missingResponse()); + const files: WorkspaceTasksFileReader = { readFile: () => Promise.reject(new Error("Path does not exist")) }; - await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + await expect(loadWorkspaceTasksConfig(files)).resolves.toEqual({ kind: "missing", message: "No workspace tasks configured here.", hint: `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`, }); }); - it("returns a visible unavailable state instead of throwing on request failures", async () => { - const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 })); + it("returns a visible unavailable state instead of throwing on read failures", async () => { + const files: WorkspaceTasksFileReader = { readFile: () => Promise.reject(new Error("nope")) }; - await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + await expect(loadWorkspaceTasksConfig(files)).resolves.toMatchObject({ kind: "unavailable", message: "Could not load workspace tasks.", hint: `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`, - detail: `Unable to read ${TASKS_CONFIG_PATH}: HTTP 400: nope`, + detail: `Unable to read ${TASKS_CONFIG_PATH}: nope`, }); }); it("returns parser details for invalid config files", async () => { - const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + const files = reader({ content: JSON.stringify({ version: 2, tasks: [] }), truncated: false, binary: false, - })); + }); - await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + await expect(loadWorkspaceTasksConfig(files)).resolves.toMatchObject({ kind: "unavailable", detail: "Config version must be 1", }); }); - - it("validates workspace file responses", () => { - expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false }); - expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined(); - }); }); -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); -} - -function missingResponse(): Response { - return new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 }); +function reader(file: Awaited>): WorkspaceTasksFileReader { + return { readFile: () => Promise.resolve(file) }; } diff --git a/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts b/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts index 835ba73..63dd724 100644 --- a/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts +++ b/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts @@ -1,4 +1,3 @@ -import type { Workspace } from "@jmfederico/pi-web/plugin-api"; import { TASKS_CONFIG_PATH, parseTasksConfigText, type WorkspaceTasksConfig } from "./config.js"; export const tasksConfigMissingMessage = "No workspace tasks configured here."; @@ -8,46 +7,30 @@ export const tasksConfigRefreshHint = `Fix ${TASKS_CONFIG_PATH}, then click Refr const missingWorkspaceFileError = "Path does not exist"; -export type FetchLike = (input: string, init?: RequestInit) => Promise; +export interface WorkspaceTasksFileReader { + readFile(path: string): Promise; +} + +interface WorkspaceTasksFileContent { + content: string; + truncated: boolean; + binary: boolean; +} export type WorkspaceTasksConfigLoadResult = | { kind: "loaded"; config: WorkspaceTasksConfig; path: string } | { kind: "missing"; message: string; hint: string } | { kind: "unavailable"; message: string; hint: string; detail?: string }; -interface WorkspaceFileResponse { - content: string; - truncated: boolean; - binary: boolean; -} - -export async function loadWorkspaceTasksConfig( - workspace: Workspace, - deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) }, -): Promise { - let response: Response; +export async function loadWorkspaceTasksConfig(files: WorkspaceTasksFileReader): Promise { + let file: WorkspaceTasksFileContent; try { - response = await deps.fetch(workspaceFileUrl(workspace, TASKS_CONFIG_PATH), { cache: "no-store" }); + file = await files.readFile(TASKS_CONFIG_PATH); } catch (error) { + if (errorMessage(error) === missingWorkspaceFileError) return missing(); return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`); } - if (!response.ok) { - const errorMessage = await readResponseErrorMessage(response); - if (errorMessage === missingWorkspaceFileError) return missing(); - const responseSummary = errorMessage === undefined ? `HTTP ${String(response.status)}` : `HTTP ${String(response.status)}: ${errorMessage}`; - return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${responseSummary}`); - } - - let body: unknown; - try { - body = await response.json(); - } catch (error) { - return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`); - } - - const file = parseWorkspaceFileResponse(body); - if (file === undefined) return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}`); if (file.binary) return unavailable(`${TASKS_CONFIG_PATH} must be a text file`); if (file.truncated) return unavailable(`${TASKS_CONFIG_PATH} is too large and was truncated`); @@ -56,19 +39,6 @@ export async function loadWorkspaceTasksConfig( return { kind: "loaded", config: result.config, path: TASKS_CONFIG_PATH }; } -export function workspaceFileUrl(workspace: Workspace, path: string): string { - return `/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/file?path=${encodeURIComponent(path)}`; -} - -export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileResponse | undefined { - if (!isRecord(value)) return undefined; - const content = value["content"]; - const truncated = value["truncated"]; - const binary = value["binary"]; - if (typeof content !== "string" || typeof truncated !== "boolean" || typeof binary !== "boolean") return undefined; - return { content, truncated, binary }; -} - function missing(): WorkspaceTasksConfigLoadResult { return { kind: "missing", @@ -86,21 +56,10 @@ function unavailable(detail: string): WorkspaceTasksConfigLoadResult { }; } -async function readResponseErrorMessage(response: Response): Promise { - try { - const body: unknown = await response.json(); - if (!isRecord(body)) return undefined; - const error = body["error"]; - return typeof error === "string" ? error : undefined; - } catch { - return undefined; - } +function errorMessage(error: unknown): string | undefined { + return error instanceof Error ? error.message : undefined; } function formatUnknownError(error: unknown): string { return error instanceof Error ? error.message : String(error); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/plugin-api.d.ts b/plugin-api.d.ts index ff2b9a8..a274141 100644 --- a/plugin-api.d.ts +++ b/plugin-api.d.ts @@ -1,153 +1 @@ -import type { TemplateResult } from "lit"; - -export type PluginId = string; -export type LocalContributionId = string; -export type QualifiedContributionId = string; -export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; -export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; - -export interface PiWebPlugin { - apiVersion: 1; - name: string; - activate: (context: PluginActivationContext) => PluginActivationResult; -} - -export interface PluginActivationContext { - apiVersion: 1; - pluginId: PluginId; - html: HtmlTemplateTag; - svg: SvgTemplateTag; -} - -export interface PluginActivationResult { - contributions: PluginContributions; -} - -export interface PluginContributions { - actions?: PluginAction[]; - workspacePanels?: WorkspacePanelContribution[]; - workspaceLabels?: WorkspaceLabelContribution[]; - themes?: ThemeContribution[]; - themePairs?: ThemePairContribution[]; -} - -export interface PluginRuntimeState { - selectedWorkspace?: Workspace; - selectedSession?: unknown; - workspaceTool?: string; - mainView?: string; - piWebStatus?: unknown; -} - -export interface PluginRuntimeContext { - state: PluginRuntimeState; - openActionPalette: () => void; - focusPrompt: () => void; - addProject: () => void | Promise; - configureAuth: () => void | Promise; - logoutAuth: () => void | Promise; - openThemePicker: () => void; - selectMainView: (view: string) => void; - selectWorkspaceTool: (tool: QualifiedContributionId) => void; - openTerminal: (options?: { terminalId?: string | undefined }) => void; - refreshFiles: () => void | Promise; - refreshGit: () => void | Promise; - refreshAppData: () => void | Promise; - reloadPage: () => void; - startSession: () => void | Promise; - archiveSession: () => void | Promise; - stopActiveWork: () => void | Promise; -} - -export interface PluginAction { - id: LocalContributionId; - title: string; - description?: string; - shortcut?: string; - group?: string; - enabled?: (context: PluginRuntimeContext) => boolean; - run: (context: PluginRuntimeContext) => void | Promise; -} - -export interface Workspace { - id: string; - projectId: string; - path: string; - label: string; - branch?: string; - isMain: boolean; - isGitRepo: boolean; - isGitWorktree: boolean; -} - -export interface WorkspacePanelContext { - workspace: Workspace; - state?: PluginRuntimeState; - openTerminal: (options?: { terminalId?: string | undefined }) => void; -} - -export type WorkspacePanelIcon = TemplateResult; - -export interface WorkspacePanelContribution { - id: LocalContributionId; - title: string; - icon?: WorkspacePanelIcon; - order?: number; - visible?: (context: WorkspacePanelContext) => boolean; - badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; - render: (context: WorkspacePanelContext) => TemplateResult; -} - -export interface WorkspaceLabelContext { - workspace: Workspace; - state?: PluginRuntimeState; -} - -export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem; - -export interface WorkspaceLabelTextItem { - type: "text"; - text: string; - title?: string; -} - -export interface WorkspaceLabelLinkItem { - type: "link"; - text: string; - href: string; - title?: string; - target?: "_blank" | "_self"; -} - -export interface WorkspaceLabelRenderItem { - type: "render"; - render: () => TemplateResult; -} - -export interface WorkspaceLabelContribution { - id: LocalContributionId; - order?: number; - visible?: (context: WorkspaceLabelContext) => boolean; - items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[]; -} - -export type ThemeColorScheme = "dark" | "light"; -export type ThemeTokens = Record; - -export interface ThemeContribution { - id: LocalContributionId; - name: string; - description?: string; - order?: number; - colorScheme: ThemeColorScheme; - tokens: ThemeTokens; -} - -export interface ThemePairContribution { - id: LocalContributionId; - name: string; - description?: string; - order?: number; - light: LocalContributionId; - dark: LocalContributionId; -} +export * from "./dist/plugin-api.js"; diff --git a/plugin-api/unstable.d.ts b/plugin-api/unstable.d.ts new file mode 100644 index 0000000..ac8f1ee --- /dev/null +++ b/plugin-api/unstable.d.ts @@ -0,0 +1 @@ +export * from "../dist/plugin-api/unstable.js"; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index b98e7b9..d1f5735 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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): 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 diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 99b509f..9e07c51 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -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", diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 24214fa..c04c270 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -86,7 +86,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp

    Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}

    `; } - 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`
    ${file.path}${metadata}
    @@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp function renderTerminal(context: WorkspacePanelContext): TemplateResult { loadTerminalPanel(); - return html``; + return html``; } function renderGit(context: WorkspacePanelContext): TemplateResult { diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 6673508..98f2045 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -11,7 +11,7 @@ function createContext(statePatch: Partial = {}) { const calls: string[] = []; const context: PluginRuntimeContext = { state: { ...initialAppState(), ...statePatch }, - piWebInternal: { + piWebUnstable: { terminalCommandRuns: { runCommand: vi.fn(), listCommandRuns: vi.fn(), diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 1822172..01eef8f 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -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): 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 267b0aa..7ce764a 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -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; +} + +export type WorkspaceTerminalCommandInput = Omit; + +export interface WorkspacePanelTerminal { + open(options?: { terminalId?: string | undefined }): void; + runCommand(input: WorkspaceTerminalCommandInput): Promise; +} + +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; @@ -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; fileTree: FileTreeEntry[]; expandedDirs: Record; 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; } diff --git a/src/plugin-api.ts b/src/plugin-api.ts new file mode 100644 index 0000000..cc4bd9c --- /dev/null +++ b/src/plugin-api.ts @@ -0,0 +1,203 @@ +import type { TemplateResult } from "lit"; +import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle } from "./shared/apiTypes.js"; + +export type { + FileContentMediaType, + FileContentResponse, + FileTreeEntry, + FileTreeResponse, + MachineKind, + PiWebComponentStatus, + PiWebInstallationInfo, + PiWebInstallationKind, + PiWebReleaseStatus, + PiWebServiceComponent, + PiWebStatusMessage, + PiWebStatusResponse, + PiWebStatusSeverity, + PiWebVersionResponse, + TerminalCommandRun, + TerminalCommandRunFilter, + TerminalCommandRunHandle, + TerminalCommandRunStatus, +} from "./shared/apiTypes.js"; + +export type PluginId = string; +export type LocalContributionId = string; +export type QualifiedContributionId = string; +export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; +export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; + +export interface PiWebPlugin { + apiVersion: 1; + name: string; + activate: (context: PluginActivationContext) => PluginActivationResult; +} + +export interface PluginActivationContext { + apiVersion: 1; + pluginId: PluginId; + html: HtmlTemplateTag; + svg: SvgTemplateTag; +} + +export interface PluginActivationResult { + contributions: PluginContributions; +} + +export interface PluginContributions { + actions?: PluginAction[]; + workspacePanels?: WorkspacePanelContribution[]; + workspaceLabels?: WorkspaceLabelContribution[]; + themes?: ThemeContribution[]; + themePairs?: ThemePairContribution[]; +} + +export interface PluginMachine { + id: string; + name: string; + kind: MachineKind; +} + +export interface PluginRuntimeState { + selectedWorkspace?: Workspace; + selectedSession?: unknown; + workspaceTool?: string; + mainView?: string; + piWebStatus?: PiWebStatusResponse; +} + +export interface PluginRuntimeContext { + state: PluginRuntimeState; + openActionPalette: () => void; + focusPrompt: () => void; + addProject: () => void | Promise; + configureAuth: () => void | Promise; + logoutAuth: () => void | Promise; + openThemePicker: () => void; + selectMainView: (view: string) => void; + selectWorkspaceTool: (tool: QualifiedContributionId) => void; + openTerminal: (options?: { terminalId?: string | undefined }) => void; + refreshFiles: () => void | Promise; + refreshGit: () => void | Promise; + refreshAppData: () => void | Promise; + reloadPage: () => void; + startSession: () => void | Promise; + archiveSession: () => void | Promise; + stopActiveWork: () => void | Promise; +} + +export interface PluginAction { + id: LocalContributionId; + title: string; + description?: string; + shortcut?: string; + group?: string; + enabled?: (context: PluginRuntimeContext) => boolean; + run: (context: PluginRuntimeContext) => void | Promise; +} + +export interface Workspace { + id: string; + projectId: string; + path: string; + label: string; + branch?: string; + isMain: boolean; + isGitRepo: boolean; + isGitWorktree: boolean; +} + +export interface WorkspacePanelFiles { + readFile(path: string): Promise; +} + +export interface WorkspaceTerminalCommandInput { + title: string; + command: string; + metadata?: Record; + open?: boolean; +} + +export interface WorkspacePanelTerminal { + open(options?: { terminalId?: string | undefined }): void; + runCommand(input: WorkspaceTerminalCommandInput): Promise; +} + +export interface WorkspacePanelContext { + machine: PluginMachine; + workspace: Workspace; + state?: PluginRuntimeState; + files: WorkspacePanelFiles; + terminal: WorkspacePanelTerminal; + requestRender: () => void; + openTerminal: (options?: { terminalId?: string | undefined }) => void; +} + +export type WorkspacePanelIcon = TemplateResult; + +export interface WorkspacePanelContribution { + id: LocalContributionId; + title: string; + icon?: WorkspacePanelIcon; + order?: number; + visible?: (context: WorkspacePanelContext) => boolean; + badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; + render: (context: WorkspacePanelContext) => TemplateResult; +} + +export interface WorkspaceLabelContext { + machine: PluginMachine; + workspace: Workspace; + state?: PluginRuntimeState; +} + +export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem; + +export interface WorkspaceLabelTextItem { + type: "text"; + text: string; + title?: string; +} + +export interface WorkspaceLabelLinkItem { + type: "link"; + text: string; + href: string; + title?: string; + target?: "_blank" | "_self"; +} + +export interface WorkspaceLabelRenderItem { + type: "render"; + render: () => TemplateResult; +} + +export interface WorkspaceLabelContribution { + id: LocalContributionId; + order?: number; + visible?: (context: WorkspaceLabelContext) => boolean; + items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[]; +} + +export type ThemeColorScheme = "dark" | "light"; +export type ThemeTokens = Record; + +export interface ThemeContribution { + id: LocalContributionId; + name: string; + description?: string; + order?: number; + colorScheme: ThemeColorScheme; + tokens: ThemeTokens; +} + +export interface ThemePairContribution { + id: LocalContributionId; + name: string; + description?: string; + order?: number; + light: LocalContributionId; + dark: LocalContributionId; +} + diff --git a/src/plugin-api/unstable.ts b/src/plugin-api/unstable.ts new file mode 100644 index 0000000..1d11e4e --- /dev/null +++ b/src/plugin-api/unstable.ts @@ -0,0 +1,25 @@ +import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace, WorkspaceTerminalCommandInput } from "../plugin-api.js"; + +export interface UnstableRunTerminalCommandInput extends WorkspaceTerminalCommandInput { + workspace: Workspace; +} + +export interface UnstableTerminalCommandRunsRuntime { + runCommand(input: UnstableRunTerminalCommandInput): Promise; + listCommandRuns(filter?: TerminalCommandRunFilter): Promise; + getCommandRun(runId: string): Promise; + open(options?: { terminalId?: string | undefined }): void; +} + +export interface UnstableRuntimeCapabilities { + terminalCommandRuns: UnstableTerminalCommandRunsRuntime; + openSettings?: (section?: string) => void; +} + +export interface UnstablePluginRuntimeContext { + piWebUnstable?: UnstableRuntimeCapabilities; +} + +export interface UnstableWorkspacePanelContext { + piWebUnstable?: Pick; +} diff --git a/tsconfig.json b/tsconfig.json index 40866c9..c8c9370 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ ], "baseUrl": ".", "paths": { - "@jmfederico/pi-web/plugin-api": ["./plugin-api.d.ts"] + "@jmfederico/pi-web/plugin-api": ["./src/plugin-api.ts"], + "@jmfederico/pi-web/plugin-api/unstable": ["./src/plugin-api/unstable.ts"] } }, "include": [ @@ -35,7 +36,6 @@ "vite.config.ts", "vitest.config.ts", "extensions/**/*.ts", - "pi-web-plugins/**/*.ts", - "plugin-api.d.ts" + "pi-web-plugins/**/*.ts" ] } diff --git a/tsconfig.plugin-api.json b/tsconfig.plugin-api.json new file mode 100644 index 0000000..68efe16 --- /dev/null +++ b/tsconfig.plugin-api.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/plugin-api.ts", + "src/plugin-api/**/*.ts", + "src/shared/apiTypes.ts" + ] +}