Archived
feat: add public plugin workspace capabilities
This commit is contained in:
@@ -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.
|
||||||
@@ -110,7 +110,7 @@ Remote model-provider credentials and OAuth state stay on the target machine. AP
|
|||||||
|
|
||||||
## Plugins
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -84,10 +84,11 @@
|
|||||||
<li><strong>Workspace tools and panels</strong> next to Files, Git, and Terminal.</li>
|
<li><strong>Workspace tools and panels</strong> next to Files, Git, and Terminal.</li>
|
||||||
<li><strong>Workspace labels</strong> in the workspace list, header, and status bar.</li>
|
<li><strong>Workspace labels</strong> in the workspace list, header, and status bar.</li>
|
||||||
<li><strong>Static assets</strong> served from the plugin folder.</li>
|
<li><strong>Static assets</strong> served from the plugin folder.</li>
|
||||||
<li><strong>Browser-side integrations</strong> using PI WEB HTTP/WebSocket APIs.</li>
|
<li><strong>Browser-side integrations</strong> using documented PI WEB plugin context helpers.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Plugins cannot extend the session daemon or add server-side hooks. They run in the browser UI only.
|
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.
|
||||||
</p>
|
</p>
|
||||||
<div class="callout">
|
<div class="callout">
|
||||||
The plugin API is intentionally limited and actively developed. Feedback is appreciated: if an extension
|
The plugin API is intentionally limited and actively developed. Feedback is appreciated: if an extension
|
||||||
|
|||||||
+91
-47
@@ -7,7 +7,8 @@ Plugins can currently:
|
|||||||
- add action-palette commands;
|
- add action-palette commands;
|
||||||
- add workspace tools/panels next to Files, Git, and Terminal;
|
- add workspace tools/panels next to Files, Git, and Terminal;
|
||||||
- add compact workspace-label items in the workspace list, panel header, and status bar;
|
- 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.
|
- 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.
|
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:
|
Plugins run as JavaScript in the browser app. Treat them as trusted code:
|
||||||
|
|
||||||
- they can call browser APIs;
|
- they can call browser APIs;
|
||||||
- they can `fetch()` PI WEB API endpoints using the current browser access;
|
- they can read workspace files and start terminal commands through documented plugin helpers;
|
||||||
- they can read workspace files through PI WEB's file endpoints if the UI can read them;
|
|
||||||
- they can render arbitrary Lit templates/custom elements in plugin contribution areas;
|
- they can render arbitrary Lit templates/custom elements in plugin contribution areas;
|
||||||
- they should not be installed from untrusted sources.
|
- 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
|
## 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.
|
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: {
|
state: {
|
||||||
selectedWorkspace?: Workspace;
|
selectedWorkspace?: Workspace;
|
||||||
selectedSession?: unknown;
|
selectedSession?: unknown;
|
||||||
|
piWebStatus?: PiWebStatusResponse;
|
||||||
};
|
};
|
||||||
openActionPalette: () => void;
|
openActionPalette: () => void;
|
||||||
focusPrompt: () => void;
|
focusPrompt: () => void;
|
||||||
@@ -424,12 +427,12 @@ interface PluginRuntimeContext {
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `state` is a snapshot of current UI state when actions are built.
|
- `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.
|
- 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.
|
- `enabled` is evaluated when the action palette asks for actions.
|
||||||
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
- `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.
|
- `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
|
#### Keyboard shortcuts
|
||||||
|
|
||||||
@@ -477,24 +480,45 @@ interface WorkspacePanelContribution {
|
|||||||
title: string;
|
title: string;
|
||||||
icon?: TemplateResult;
|
icon?: TemplateResult;
|
||||||
order?: number;
|
order?: number;
|
||||||
visible?: (context: { workspace: Workspace }) => boolean;
|
visible?: (context: WorkspacePanelContext) => boolean;
|
||||||
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
|
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
|
||||||
render: (context: WorkspacePanelContext) => TemplateResult;
|
render: (context: WorkspacePanelContext) => TemplateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspacePanelContext {
|
interface WorkspacePanelContext {
|
||||||
|
machine: PluginMachine;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
|
state?: PluginRuntimeState;
|
||||||
|
files: {
|
||||||
|
readFile(path: string): Promise<FileContentResponse>;
|
||||||
|
};
|
||||||
|
terminal: {
|
||||||
|
open(options?: { terminalId?: string }): void;
|
||||||
|
runCommand(input: {
|
||||||
|
title: string;
|
||||||
|
command: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
open?: boolean;
|
||||||
|
}): Promise<TerminalCommandRunHandle>;
|
||||||
|
};
|
||||||
|
requestRender: () => void;
|
||||||
openTerminal: (options?: { terminalId?: string }) => 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.
|
`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
|
```ts
|
||||||
|
interface PluginMachine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: "local" | "remote";
|
||||||
|
}
|
||||||
|
|
||||||
interface Workspace {
|
interface Workspace {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: 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.
|
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
|
### Workspace labels
|
||||||
@@ -543,11 +569,13 @@ interface WorkspaceLabelContribution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceLabelContext {
|
interface WorkspaceLabelContext {
|
||||||
|
machine: PluginMachine;
|
||||||
workspace: Workspace;
|
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.
|
Items are sorted by `order` and then id. Return an empty array to render nothing.
|
||||||
|
|
||||||
@@ -609,55 +637,71 @@ export default {
|
|||||||
|
|
||||||
## Reading workspace files
|
## 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
|
```js
|
||||||
async function readWorkspaceFile(workspace, path) {
|
workspacePanels: [
|
||||||
const url =
|
{
|
||||||
`/api/projects/${encodeURIComponent(workspace.projectId)}` +
|
id: "workspace.env",
|
||||||
`/workspaces/${encodeURIComponent(workspace.id)}` +
|
title: "Env",
|
||||||
`/file?path=${encodeURIComponent(path)}`;
|
render: ({ files, requestRender }) => html`
|
||||||
|
<my-env-viewer .files=${files} .requestRender=${requestRender}></my-env-viewer>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
class MyEnvViewer extends HTMLElement {
|
||||||
if (!response.ok) throw new Error(`Failed to read ${path}: ${response.status}`);
|
set files(value) {
|
||||||
return await response.json();
|
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.
|
```js
|
||||||
|
render: ({ terminal }) => html`
|
||||||
Common read endpoints:
|
<button @click=${() => terminal.runCommand({
|
||||||
|
title: "Build",
|
||||||
```text
|
command: "npm run build",
|
||||||
GET /api/projects
|
open: true,
|
||||||
GET /api/projects/:projectId/workspaces
|
metadata: { "my-plugin.task": "build" },
|
||||||
GET /api/projects/:projectId/workspaces/:workspaceId/tree?path=<dir>
|
})}>Build</button>
|
||||||
GET /api/projects/:projectId/workspaces/:workspaceId/file?path=<file>
|
`
|
||||||
GET /api/projects/:projectId/workspaces/:workspaceId/git/status
|
|
||||||
GET /api/projects/:projectId/workspaces/:workspaceId/git/diff?path=<file>&staged=true|false
|
|
||||||
GET /api/sessions?cwd=<workspace-path>
|
|
||||||
GET /api/sessions/:sessionId/status
|
|
||||||
GET /api/sessions/:sessionId/messages?before=<cursor>&limit=<n>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Common write/action endpoints:
|
Review command strings carefully. They are trusted shell commands executed in the workspace terminal.
|
||||||
|
|
||||||
```text
|
## Internal PI WEB APIs and explicit unstable opt-in
|
||||||
POST /api/sessions { "cwd": "/path/to/workspace" }
|
|
||||||
POST /api/sessions/:id/prompt { "text": "...", "streamingBehavior": "steer" | "followUp" }
|
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.
|
||||||
POST /api/sessions/:id/shell { "text": "..." }
|
|
||||||
POST /api/sessions/:id/stop
|
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:
|
||||||
POST /api/sessions/:id/archive
|
|
||||||
POST /api/sessions/:id/restore
|
```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
|
## 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;
|
- simple contributions should be synchronous and cheap;
|
||||||
- expensive or async work should live inside the plugin;
|
- 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;
|
- 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()`.
|
- clean up intervals/event listeners in custom elements' `disconnectedCallback()`.
|
||||||
|
|
||||||
## Agent implementation checklist
|
## 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.
|
9. Add workspace panels for larger workspace UI.
|
||||||
10. Add workspace labels for compact inline metadata.
|
10. Add workspace labels for compact inline metadata.
|
||||||
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
|
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.
|
12. Use documented context helpers first: `files`, `terminal`, `requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
|
||||||
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.
|
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.
|
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.
|
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -18,7 +18,8 @@
|
|||||||
"extensions",
|
"extensions",
|
||||||
"docs/plugins.md",
|
"docs/plugins.md",
|
||||||
"docs/assets",
|
"docs/assets",
|
||||||
"plugin-api.d.ts"
|
"plugin-api.d.ts",
|
||||||
|
"plugin-api/unstable.d.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'",
|
"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:server": "npm run dev:web",
|
||||||
"dev:client": "vite --host 0.0.0.0",
|
"dev:client": "vite --host 0.0.0.0",
|
||||||
"dev:plugins": "node scripts/build-plugins.mjs --watch",
|
"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",
|
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
|
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.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 = {
|
const plugin: PiWebPlugin = {
|
||||||
apiVersion: 1,
|
apiVersion: 1,
|
||||||
|
|||||||
@@ -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<string[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import type { AppState } from "../../src/client/src/appState";
|
import type { HtmlTemplateTag, PiWebComponentStatus, PiWebInstallationInfo, PiWebPlugin, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
|
||||||
import type { HtmlTemplateTag, PiWebPlugin } from "../../src/client/src/plugins/types";
|
|
||||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse } from "../../src/shared/apiTypes";
|
|
||||||
|
|
||||||
function messagesFor(state: AppState): PiWebStatusMessage[] {
|
function messagesFor(state: PluginRuntimeState | undefined): PiWebStatusMessage[] {
|
||||||
return state.piWebStatus?.messages ?? [];
|
return state?.piWebStatus?.messages ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusFor(state: AppState): PiWebStatusResponse | undefined {
|
function statusFor(state: PluginRuntimeState | undefined): PiWebStatusResponse | undefined {
|
||||||
return state.piWebStatus;
|
return state?.piWebStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageCount(state: AppState): number {
|
function messageCount(state: PluginRuntimeState | undefined): number {
|
||||||
return messagesFor(state).length;
|
return messagesFor(state).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | unde
|
|||||||
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
|
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowUpdatesPanel(state: AppState): boolean {
|
function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean {
|
||||||
const status = statusFor(state);
|
const status = statusFor(state);
|
||||||
if (messageCount(state) > 0) return true;
|
if (messageCount(state) > 0) return true;
|
||||||
if (status === undefined) return false;
|
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);
|
const status = statusFor(state);
|
||||||
if (status === undefined) {
|
if (status === undefined) {
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
|
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
|
||||||
import { TASKS_CONFIG_PATH } from "./config.js";
|
import { TASKS_CONFIG_PATH } from "./config.js";
|
||||||
import { defineTasksPanelElement, tasksPanelBadge } from "./tasksPanelElement.js";
|
import { defineTasksPanelElement, tasksPanelBadge } from "./tasksPanelElement.js";
|
||||||
import { terminalCommandRunsFromContext } from "./piWebInternal.js";
|
|
||||||
|
|
||||||
const plugin: PiWebPlugin = {
|
const plugin: PiWebPlugin = {
|
||||||
apiVersion: 1,
|
apiVersion: 1,
|
||||||
@@ -39,8 +38,8 @@ const plugin: PiWebPlugin = {
|
|||||||
</svg>
|
</svg>
|
||||||
`,
|
`,
|
||||||
order: 40,
|
order: 40,
|
||||||
badge: ({ workspace }) => tasksPanelBadge(workspace),
|
badge: (context) => tasksPanelBadge(context),
|
||||||
render: (context) => html`<pi-web-workspace-tasks-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-workspace-tasks-panel>`,
|
render: (context) => html`<pi-web-workspace-tasks-panel .context=${context}></pi-web-workspace-tasks-panel>`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
|
||||||
|
|
||||||
export interface InternalRunTerminalCommandInput {
|
|
||||||
workspace: Workspace;
|
|
||||||
title: string;
|
|
||||||
command: string;
|
|
||||||
metadata?: Record<string, string>;
|
|
||||||
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<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InternalTerminalCommandRunHandle {
|
|
||||||
run: InternalTerminalCommandRun;
|
|
||||||
completed: Promise<InternalTerminalCommandRun>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InternalTerminalCommandRunsRuntime {
|
|
||||||
runCommand(input: InternalRunTerminalCommandInput): Promise<InternalTerminalCommandRunHandle>;
|
|
||||||
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<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== 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<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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 { runWorkspaceTaskInTerminal } from "./taskRunner";
|
||||||
import type { WorkspaceTask } from "./config";
|
import type { WorkspaceTask } from "./config";
|
||||||
import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal";
|
|
||||||
|
|
||||||
const workspace: Workspace = {
|
const run: TerminalCommandRun = {
|
||||||
id: "workspace 1",
|
|
||||||
projectId: "project/1",
|
|
||||||
path: "/repo",
|
|
||||||
label: "repo",
|
|
||||||
isMain: false,
|
|
||||||
isGitRepo: true,
|
|
||||||
isGitWorktree: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const run: InternalTerminalCommandRun = {
|
|
||||||
id: "run1",
|
id: "run1",
|
||||||
origin: "workspace-tasks",
|
origin: "workspace-tasks",
|
||||||
projectId: workspace.projectId,
|
projectId: "project/1",
|
||||||
workspaceId: workspace.id,
|
workspaceId: "workspace 1",
|
||||||
terminalId: "term1",
|
terminalId: "term1",
|
||||||
title: "Build",
|
title: "Build",
|
||||||
command: "npm run build",
|
command: "npm run build",
|
||||||
@@ -28,20 +17,19 @@ const run: InternalTerminalCommandRun = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("task runner", () => {
|
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 task: WorkspaceTask = { id: "build", title: "Build", command: "npm run build", confirm: false };
|
||||||
const runCommand = vi.fn<InternalTerminalCommandRunsRuntime["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
|
const runCommand = vi.fn<WorkspacePanelTerminal["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
|
||||||
const terminal: InternalTerminalCommandRunsRuntime = {
|
const terminal: WorkspacePanelTerminal = {
|
||||||
runCommand,
|
runCommand,
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
|
const handle = await runWorkspaceTaskInTerminal(terminal, task);
|
||||||
|
|
||||||
expect(handle.run).toEqual(run);
|
expect(handle.run).toEqual(run);
|
||||||
await expect(handle.completed).resolves.toEqual(run);
|
await expect(handle.completed).resolves.toEqual(run);
|
||||||
expect(runCommand).toHaveBeenCalledWith({
|
expect(runCommand).toHaveBeenCalledWith({
|
||||||
workspace,
|
|
||||||
title: "Build",
|
title: "Build",
|
||||||
command: "npm run build",
|
command: "npm run build",
|
||||||
open: true,
|
open: true,
|
||||||
|
|||||||
@@ -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 { WorkspaceTask } from "./config.js";
|
||||||
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
|
|
||||||
|
|
||||||
export function runWorkspaceTaskInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, task: WorkspaceTask): ReturnType<InternalTerminalCommandRunsRuntime["runCommand"]> {
|
export function runWorkspaceTaskInTerminal(terminal: WorkspacePanelTerminal, task: WorkspaceTask): ReturnType<WorkspacePanelTerminal["runCommand"]> {
|
||||||
return terminal.runCommand({
|
return terminal.runCommand({
|
||||||
workspace,
|
|
||||||
title: task.title,
|
title: task.title,
|
||||||
command: task.command,
|
command: task.command,
|
||||||
open: true,
|
open: true,
|
||||||
|
|||||||
@@ -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 { TASKS_CONFIG_PATH, type WorkspaceTask } from "./config.js";
|
||||||
import { runWorkspaceTaskInTerminal } from "./taskRunner.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";
|
import { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage, type WorkspaceTasksConfigLoadResult } from "./workspaceTasksClient.js";
|
||||||
|
|
||||||
export const tasksPanelTagName = "pi-web-workspace-tasks-panel";
|
export const tasksPanelTagName = "pi-web-workspace-tasks-panel";
|
||||||
|
|
||||||
export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void;
|
|
||||||
|
|
||||||
const configChangedEvent = "pi-web-workspace-tasks-config-changed";
|
const configChangedEvent = "pi-web-workspace-tasks-config-changed";
|
||||||
|
|
||||||
type ConfigState =
|
type ConfigState =
|
||||||
@@ -27,17 +23,15 @@ export function defineTasksPanelElement(): void {
|
|||||||
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
|
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tasksPanelBadge(workspace: Workspace): string | number | undefined {
|
export function tasksPanelBadge(context: WorkspacePanelContext): string | number | undefined {
|
||||||
const state = getCachedWorkspaceConfig(workspace);
|
const state = getCachedWorkspaceConfig(context);
|
||||||
if (state?.kind === "unavailable") return "!";
|
if (state?.kind === "unavailable") return "!";
|
||||||
if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length;
|
if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PiWebTasksPanel extends HTMLElement {
|
class PiWebTasksPanel extends HTMLElement {
|
||||||
private workspaceValue: Workspace | undefined;
|
private contextValue: WorkspacePanelContext | undefined;
|
||||||
private openTerminalValue: OpenTerminal | undefined;
|
|
||||||
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
|
|
||||||
private runningTaskId: string | undefined;
|
private runningTaskId: string | undefined;
|
||||||
private status: TaskStatus | undefined;
|
private status: TaskStatus | undefined;
|
||||||
private readonly root: ShadowRoot;
|
private readonly root: ShadowRoot;
|
||||||
@@ -50,10 +44,10 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
this.root = this.attachShadow({ mode: "open" });
|
this.root = this.attachShadow({ mode: "open" });
|
||||||
}
|
}
|
||||||
|
|
||||||
set workspace(value: Workspace | undefined) {
|
set context(value: WorkspacePanelContext | undefined) {
|
||||||
const previousKey = this.workspaceValue === undefined ? undefined : cacheKeyForWorkspace(this.workspaceValue);
|
const previousKey = this.contextValue === undefined ? undefined : cacheKeyForContext(this.contextValue);
|
||||||
const nextKey = value === undefined ? undefined : cacheKeyForWorkspace(value);
|
const nextKey = value === undefined ? undefined : cacheKeyForContext(value);
|
||||||
this.workspaceValue = value;
|
this.contextValue = value;
|
||||||
// Parent app updates should not rebuild this shadow DOM for the same workspace:
|
// 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.
|
// doing so resets the mobile scroll position and can replace buttons mid-click.
|
||||||
if (previousKey === nextKey) return;
|
if (previousKey === nextKey) return;
|
||||||
@@ -62,14 +56,6 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
set openTerminal(value: OpenTerminal | undefined) {
|
|
||||||
this.openTerminalValue = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
set terminalCommandRuns(value: InternalTerminalCommandRunsRuntime | undefined) {
|
|
||||||
this.terminalCommandRunsValue = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
window.addEventListener(configChangedEvent, this.onConfigChanged);
|
window.addEventListener(configChangedEvent, this.onConfigChanged);
|
||||||
this.render();
|
this.render();
|
||||||
@@ -80,13 +66,13 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private render(): void {
|
private render(): void {
|
||||||
const workspace = this.workspaceValue;
|
const context = this.contextValue;
|
||||||
if (workspace === undefined) {
|
if (context === undefined) {
|
||||||
this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
|
this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = getOrLoadWorkspaceConfig(workspace);
|
const state = getOrLoadWorkspaceConfig(context);
|
||||||
this.root.innerHTML = `
|
this.root.innerHTML = `
|
||||||
${taskStyles()}
|
${taskStyles()}
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
@@ -103,12 +89,12 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
|
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]")) {
|
for (const button of this.root.querySelectorAll("button[data-task-id]")) {
|
||||||
button.addEventListener("click", () => {
|
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<void> {
|
private dispatchTaskById(context: WorkspacePanelContext, taskId: string | null): Promise<void> {
|
||||||
if (!this.isCurrentWorkspace(workspace)) return Promise.resolve();
|
if (!this.isCurrentContext(context)) return Promise.resolve();
|
||||||
const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), taskId);
|
const task = taskFromConfigState(getCachedWorkspaceConfig(context), taskId);
|
||||||
if (task === undefined) {
|
if (task === undefined) {
|
||||||
this.status = { kind: "error", message: "That task is no longer available. Click Refresh, then try again." };
|
this.status = { kind: "error", message: "That task is no longer available. Click Refresh, then try again." };
|
||||||
this.render();
|
this.render();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
return this.dispatchTask(workspace, task);
|
return this.dispatchTask(context, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isCurrentWorkspace(workspace: Workspace): boolean {
|
private isCurrentContext(context: WorkspacePanelContext): boolean {
|
||||||
return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace);
|
return this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderConfigState(state: ConfigState): string {
|
private renderConfigState(state: ConfigState): string {
|
||||||
@@ -150,20 +136,20 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
|
return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshConfig(workspace: Workspace): Promise<void> {
|
private async refreshConfig(context: WorkspacePanelContext): Promise<void> {
|
||||||
this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}…` };
|
this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}…` };
|
||||||
configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" });
|
configCache.set(cacheKeyForContext(context), { kind: "loading" });
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
const state = await refreshWorkspaceConfig(workspace);
|
const state = await refreshWorkspaceConfig(context);
|
||||||
if (!this.isCurrentWorkspace(workspace)) return;
|
if (!this.isCurrentContext(context)) return;
|
||||||
this.status = state.kind === "loaded"
|
this.status = state.kind === "loaded"
|
||||||
? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` }
|
? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` }
|
||||||
: undefined;
|
: undefined;
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async dispatchTask(workspace: Workspace, task: WorkspaceTask): Promise<void> {
|
private async dispatchTask(context: WorkspacePanelContext, task: WorkspaceTask): Promise<void> {
|
||||||
if (this.runningTaskId !== undefined) {
|
if (this.runningTaskId !== undefined) {
|
||||||
this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." };
|
this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." };
|
||||||
this.render();
|
this.render();
|
||||||
@@ -175,20 +161,13 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
return;
|
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.runningTaskId = task.id;
|
||||||
this.status = { kind: "info", message: `Starting ${task.title}…` };
|
this.status = { kind: "info", message: `Starting ${task.title}…` };
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
|
const handle = await runWorkspaceTaskInTerminal(context.terminal, task);
|
||||||
if (!this.isCurrentWorkspace(workspace)) return;
|
if (!this.isCurrentContext(context)) return;
|
||||||
this.status = {
|
this.status = {
|
||||||
kind: "success",
|
kind: "success",
|
||||||
message: `Started terminal command “${handle.run.title}”.`,
|
message: `Started terminal command “${handle.run.title}”.`,
|
||||||
@@ -197,7 +176,7 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
this.runningTaskId = undefined;
|
this.runningTaskId = undefined;
|
||||||
this.render();
|
this.render();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isCurrentWorkspace(workspace)) return;
|
if (!this.isCurrentContext(context)) return;
|
||||||
this.runningTaskId = undefined;
|
this.runningTaskId = undefined;
|
||||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||||
this.render();
|
this.render();
|
||||||
@@ -205,50 +184,47 @@ class PiWebTasksPanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private openWorkspaceTerminal(terminalId?: string): void {
|
private openWorkspaceTerminal(terminalId?: string): void {
|
||||||
if (this.terminalCommandRunsValue !== undefined) {
|
const context = this.contextValue;
|
||||||
this.terminalCommandRunsValue.open(terminalId === undefined ? undefined : { terminalId });
|
if (context === undefined) {
|
||||||
return;
|
this.status = { kind: "error", message: "Select a workspace before opening a terminal." };
|
||||||
}
|
|
||||||
if (this.openTerminalValue === undefined) {
|
|
||||||
this.status = { kind: "error", message: "This PI WEB version does not provide terminal navigation to plugins." };
|
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (terminalId === undefined) this.openTerminalValue();
|
if (terminalId === undefined) context.terminal.open();
|
||||||
else this.openTerminalValue({ terminalId });
|
else context.terminal.open({ terminalId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined {
|
function getCachedWorkspaceConfig(context: WorkspacePanelContext): ConfigState | undefined {
|
||||||
return configCache.get(cacheKeyForWorkspace(workspace));
|
return configCache.get(cacheKeyForContext(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState {
|
function getOrLoadWorkspaceConfig(context: WorkspacePanelContext): ConfigState {
|
||||||
const cached = getCachedWorkspaceConfig(workspace);
|
const cached = getCachedWorkspaceConfig(context);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
const loading: ConfigState = { kind: "loading" };
|
const loading: ConfigState = { kind: "loading" };
|
||||||
configCache.set(cacheKeyForWorkspace(workspace), loading);
|
configCache.set(cacheKeyForContext(context), loading);
|
||||||
void refreshWorkspaceConfig(workspace);
|
void refreshWorkspaceConfig(context);
|
||||||
return loading;
|
return loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshWorkspaceConfig(workspace: Workspace): Promise<ConfigState> {
|
async function refreshWorkspaceConfig(context: WorkspacePanelContext): Promise<ConfigState> {
|
||||||
const key = cacheKeyForWorkspace(workspace);
|
const key = cacheKeyForContext(context);
|
||||||
const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({
|
const state = await loadWorkspaceTasksConfig(context.files).catch((error: unknown): ConfigState => ({
|
||||||
kind: "unavailable",
|
kind: "unavailable",
|
||||||
message: tasksConfigUnavailableMessage,
|
message: tasksConfigUnavailableMessage,
|
||||||
hint: tasksConfigRefreshHint,
|
hint: tasksConfigRefreshHint,
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
}));
|
}));
|
||||||
configCache.set(key, state);
|
configCache.set(key, state);
|
||||||
requestPiWebRender();
|
context.requestRender();
|
||||||
window.dispatchEvent(new Event(configChangedEvent));
|
window.dispatchEvent(new Event(configChangedEvent));
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheKeyForWorkspace(workspace: Workspace): string {
|
function cacheKeyForContext(context: WorkspacePanelContext): string {
|
||||||
return `${workspace.projectId}:${workspace.id}`;
|
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMissingState(state: Extract<ConfigState, { kind: "missing" }>): string {
|
function renderMissingState(state: Extract<ConfigState, { kind: "missing" }>): string {
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
|
||||||
import { TASKS_CONFIG_PATH } from "./config";
|
import { TASKS_CONFIG_PATH } from "./config";
|
||||||
import { loadWorkspaceTasksConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceTasksClient";
|
import { loadWorkspaceTasksConfig, type WorkspaceTasksFileReader } from "./workspaceTasksClient";
|
||||||
|
|
||||||
const workspace: Workspace = {
|
|
||||||
id: "workspace 1",
|
|
||||||
projectId: "project/1",
|
|
||||||
path: "/repo",
|
|
||||||
label: "repo",
|
|
||||||
isMain: false,
|
|
||||||
isGitRepo: true,
|
|
||||||
isGitWorktree: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("workspace tasks client", () => {
|
describe("workspace tasks client", () => {
|
||||||
it("builds the private workspace file URL", () => {
|
it("loads the configured path through the public workspace file helper", async () => {
|
||||||
expect(workspaceFileUrl(workspace, TASKS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Ftasks.json");
|
const readFile = vi.fn<WorkspaceTasksFileReader["readFile"]>(() => 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 () => {
|
it("loads and parses a valid tasks config through the public workspace file helper", async () => {
|
||||||
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
|
const files = reader({
|
||||||
content: JSON.stringify({ version: 1, tasks: [{ id: "build", title: "Build", command: "npm run build" }] }),
|
content: JSON.stringify({ version: 1, tasks: [{ id: "build", title: "Build", command: "npm run build" }] }),
|
||||||
truncated: false,
|
truncated: false,
|
||||||
binary: false,
|
binary: false,
|
||||||
}));
|
});
|
||||||
|
|
||||||
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({
|
await expect(loadWorkspaceTasksConfig(files)).resolves.toEqual({
|
||||||
kind: "loaded",
|
kind: "loaded",
|
||||||
path: TASKS_CONFIG_PATH,
|
path: TASKS_CONFIG_PATH,
|
||||||
config: {
|
config: {
|
||||||
@@ -36,49 +29,40 @@ describe("workspace tasks client", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("treats a missing optional tasks config as unconfigured", async () => {
|
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",
|
kind: "missing",
|
||||||
message: "No workspace tasks configured here.",
|
message: "No workspace tasks configured here.",
|
||||||
hint: `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`,
|
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 () => {
|
it("returns a visible unavailable state instead of throwing on read failures", async () => {
|
||||||
const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 }));
|
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",
|
kind: "unavailable",
|
||||||
message: "Could not load workspace tasks.",
|
message: "Could not load workspace tasks.",
|
||||||
hint: `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`,
|
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 () => {
|
it("returns parser details for invalid config files", async () => {
|
||||||
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
|
const files = reader({
|
||||||
content: JSON.stringify({ version: 2, tasks: [] }),
|
content: JSON.stringify({ version: 2, tasks: [] }),
|
||||||
truncated: false,
|
truncated: false,
|
||||||
binary: false,
|
binary: false,
|
||||||
}));
|
});
|
||||||
|
|
||||||
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
|
await expect(loadWorkspaceTasksConfig(files)).resolves.toMatchObject({
|
||||||
kind: "unavailable",
|
kind: "unavailable",
|
||||||
detail: "Config version must be 1",
|
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 {
|
function reader(file: Awaited<ReturnType<WorkspaceTasksFileReader["readFile"]>>): WorkspaceTasksFileReader {
|
||||||
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
return { readFile: () => Promise.resolve(file) };
|
||||||
}
|
|
||||||
|
|
||||||
function missingResponse(): Response {
|
|
||||||
return new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
|
||||||
import { TASKS_CONFIG_PATH, parseTasksConfigText, type WorkspaceTasksConfig } from "./config.js";
|
import { TASKS_CONFIG_PATH, parseTasksConfigText, type WorkspaceTasksConfig } from "./config.js";
|
||||||
|
|
||||||
export const tasksConfigMissingMessage = "No workspace tasks configured here.";
|
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";
|
const missingWorkspaceFileError = "Path does not exist";
|
||||||
|
|
||||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
export interface WorkspaceTasksFileReader {
|
||||||
|
readFile(path: string): Promise<WorkspaceTasksFileContent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceTasksFileContent {
|
||||||
|
content: string;
|
||||||
|
truncated: boolean;
|
||||||
|
binary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceTasksConfigLoadResult =
|
export type WorkspaceTasksConfigLoadResult =
|
||||||
| { kind: "loaded"; config: WorkspaceTasksConfig; path: string }
|
| { kind: "loaded"; config: WorkspaceTasksConfig; path: string }
|
||||||
| { kind: "missing"; message: string; hint: string }
|
| { kind: "missing"; message: string; hint: string }
|
||||||
| { kind: "unavailable"; message: string; hint: string; detail?: string };
|
| { kind: "unavailable"; message: string; hint: string; detail?: string };
|
||||||
|
|
||||||
interface WorkspaceFileResponse {
|
export async function loadWorkspaceTasksConfig(files: WorkspaceTasksFileReader): Promise<WorkspaceTasksConfigLoadResult> {
|
||||||
content: string;
|
let file: WorkspaceTasksFileContent;
|
||||||
truncated: boolean;
|
|
||||||
binary: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadWorkspaceTasksConfig(
|
|
||||||
workspace: Workspace,
|
|
||||||
deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) },
|
|
||||||
): Promise<WorkspaceTasksConfigLoadResult> {
|
|
||||||
let response: Response;
|
|
||||||
try {
|
try {
|
||||||
response = await deps.fetch(workspaceFileUrl(workspace, TASKS_CONFIG_PATH), { cache: "no-store" });
|
file = await files.readFile(TASKS_CONFIG_PATH);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (errorMessage(error) === missingWorkspaceFileError) return missing();
|
||||||
return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`);
|
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.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`);
|
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 };
|
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 {
|
function missing(): WorkspaceTasksConfigLoadResult {
|
||||||
return {
|
return {
|
||||||
kind: "missing",
|
kind: "missing",
|
||||||
@@ -86,21 +56,10 @@ function unavailable(detail: string): WorkspaceTasksConfigLoadResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readResponseErrorMessage(response: Response): Promise<string | undefined> {
|
function errorMessage(error: unknown): string | undefined {
|
||||||
try {
|
return error instanceof Error ? error.message : undefined;
|
||||||
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 formatUnknownError(error: unknown): string {
|
function formatUnknownError(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+1
-153
@@ -1,153 +1 @@
|
|||||||
import type { TemplateResult } from "lit";
|
export * from "./dist/plugin-api.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 PluginRuntimeState {
|
|
||||||
selectedWorkspace?: Workspace;
|
|
||||||
selectedSession?: unknown;
|
|
||||||
workspaceTool?: string;
|
|
||||||
mainView?: string;
|
|
||||||
piWebStatus?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginRuntimeContext {
|
|
||||||
state: PluginRuntimeState;
|
|
||||||
openActionPalette: () => void;
|
|
||||||
focusPrompt: () => void;
|
|
||||||
addProject: () => void | Promise<void>;
|
|
||||||
configureAuth: () => void | Promise<void>;
|
|
||||||
logoutAuth: () => void | Promise<void>;
|
|
||||||
openThemePicker: () => void;
|
|
||||||
selectMainView: (view: string) => void;
|
|
||||||
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
|
|
||||||
openTerminal: (options?: { terminalId?: string | undefined }) => void;
|
|
||||||
refreshFiles: () => void | Promise<void>;
|
|
||||||
refreshGit: () => void | Promise<void>;
|
|
||||||
refreshAppData: () => void | Promise<void>;
|
|
||||||
reloadPage: () => void;
|
|
||||||
startSession: () => void | Promise<void>;
|
|
||||||
archiveSession: () => void | Promise<void>;
|
|
||||||
stopActiveWork: () => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginAction {
|
|
||||||
id: LocalContributionId;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
shortcut?: string;
|
|
||||||
group?: string;
|
|
||||||
enabled?: (context: PluginRuntimeContext) => boolean;
|
|
||||||
run: (context: PluginRuntimeContext) => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string>;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "../dist/plugin-api/unstable.js";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, query, state } from "lit/decorators.js";
|
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 type { AppAction } from "../actions";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
@@ -17,7 +17,7 @@ import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelectio
|
|||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
import { selectedMachineId } from "../controllers/types";
|
import { selectedMachineId } from "../controllers/types";
|
||||||
import { RealtimeSocket } from "../sessionSocket";
|
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 { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||||
import { corePlugin } from "../plugins/core";
|
import { corePlugin } from "../plugins/core";
|
||||||
import { themePackPlugin } from "../plugins/themes";
|
import { themePackPlugin } from "../plugins/themes";
|
||||||
@@ -828,7 +828,8 @@ export class PiWebApp extends LitElement {
|
|||||||
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
||||||
const workspace = this.state.selectedWorkspace;
|
const workspace = this.state.selectedWorkspace;
|
||||||
if (workspace === undefined) return [];
|
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 {
|
private workspacePanelEmptyState(): WorkspacePanelEmptyState {
|
||||||
@@ -892,31 +893,45 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
|
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
|
||||||
const createContext = (origin: string): WorkspacePanelContext => installWorkspacePanelScope({
|
const machine = pluginMachineFromState(this.state);
|
||||||
workspace,
|
const machineId = machine.id;
|
||||||
state: this.state,
|
const createContext = (origin: string): WorkspacePanelContext => {
|
||||||
piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) },
|
const terminalCommandRuns = this.terminalCommandRunsForOrigin(origin, machineId);
|
||||||
fileTree: this.state.fileTree,
|
return installWorkspacePanelScope({
|
||||||
expandedDirs: this.state.expandedDirs,
|
machine,
|
||||||
selectedFilePath: this.state.selectedFilePath,
|
workspace,
|
||||||
selectedFileContent: this.state.selectedFileContent,
|
state: this.state,
|
||||||
fileTreeStale: this.state.fileTreeStale,
|
files: {
|
||||||
gitStatus: this.state.gitStatus,
|
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
|
||||||
selectedDiffPath: this.state.selectedDiffPath,
|
},
|
||||||
selectedDiff: this.state.selectedDiff,
|
terminal: {
|
||||||
selectedStagedDiff: this.state.selectedStagedDiff,
|
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||||
gitStale: this.state.gitStale,
|
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
|
||||||
activeTerminalCount: this.state.activeTerminalCount,
|
},
|
||||||
selectedTerminalId: this.state.selectedTerminalId,
|
requestRender: () => { this.requestUpdate(); },
|
||||||
terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id,
|
piWebUnstable: { terminalCommandRuns },
|
||||||
openTerminal: (options) => { this.openTerminal(options); },
|
fileTree: this.state.fileTree,
|
||||||
onRefreshFiles: () => { void this.files.refreshFiles(); },
|
expandedDirs: this.state.expandedDirs,
|
||||||
onExpandDir: (path: string) => { void this.files.expandDir(path); },
|
selectedFilePath: this.state.selectedFilePath,
|
||||||
onSelectFile: (path: string) => { void this.files.selectFile(path); },
|
selectedFileContent: this.state.selectedFileContent,
|
||||||
onRefreshGit: () => { void this.git.refreshGit(); },
|
fileTreeStale: this.state.fileTreeStale,
|
||||||
onSelectDiff: (path: string) => { void this.git.selectDiff(path); },
|
gitStatus: this.state.gitStatus,
|
||||||
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); },
|
selectedDiffPath: this.state.selectedDiffPath,
|
||||||
}, createContext);
|
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");
|
return createContext("core");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,7 +959,7 @@ export class PiWebApp extends LitElement {
|
|||||||
private createPluginRuntimeContext(): PluginRuntimeContext {
|
private createPluginRuntimeContext(): PluginRuntimeContext {
|
||||||
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
|
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
|
||||||
state: this.state,
|
state: this.state,
|
||||||
piWebInternal: {
|
piWebUnstable: {
|
||||||
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
|
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
|
||||||
openSettings: (section) => { this.openSettings(section); },
|
openSettings: (section) => { this.openSettings(section); },
|
||||||
},
|
},
|
||||||
@@ -1343,6 +1358,12 @@ function createPluginRegistry(): PluginRegistry {
|
|||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): PluginMachine {
|
||||||
|
const machine = state.selectedMachine;
|
||||||
|
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
||||||
|
return { id: "local", name: "local", kind: "local" };
|
||||||
|
}
|
||||||
|
|
||||||
function machineActivitySubscriptionInputsChanged(previous: AppState, next: AppState): boolean {
|
function machineActivitySubscriptionInputsChanged(previous: AppState, next: AppState): boolean {
|
||||||
return previous.machines !== next.machines
|
return previous.machines !== next.machines
|
||||||
|| previous.machineStatuses !== next.machineStatuses
|
|| previous.machineStatuses !== next.machineStatuses
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function createCoreActions(): PluginAction[] {
|
|||||||
description: "Manage PI WEB configuration and keyboard shortcuts",
|
description: "Manage PI WEB configuration and keyboard shortcuts",
|
||||||
shortcut: "mod+,",
|
shortcut: "mod+,",
|
||||||
group: "Preferences",
|
group: "Preferences",
|
||||||
run: (context) => { context.piWebInternal?.openSettings?.(); },
|
run: (context) => { context.piWebUnstable?.openSettings?.(); },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "app.refresh-data",
|
id: "app.refresh-data",
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
|
|||||||
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
|
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.state.selectedMachine?.id ?? "local" });
|
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id });
|
||||||
return html`
|
return html`
|
||||||
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
||||||
<div class="image-preview">
|
<div class="image-preview">
|
||||||
@@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
|
|||||||
|
|
||||||
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
||||||
loadTerminalPanel();
|
loadTerminalPanel();
|
||||||
return html`<terminal-panel .workspace=${context.workspace} .machineId=${context.state.selectedMachine?.id ?? "local"} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
|
return html`<terminal-panel .workspace=${context.workspace} .machineId=${context.machine.id} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const context: PluginRuntimeContext = {
|
const context: PluginRuntimeContext = {
|
||||||
state: { ...initialAppState(), ...statePatch },
|
state: { ...initialAppState(), ...statePatch },
|
||||||
piWebInternal: {
|
piWebUnstable: {
|
||||||
terminalCommandRuns: {
|
terminalCommandRuns: {
|
||||||
runCommand: vi.fn(),
|
runCommand: vi.fn(),
|
||||||
listCommandRuns: vi.fn(),
|
listCommandRuns: vi.fn(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, svg } from "lit";
|
import { html, svg } from "lit";
|
||||||
import type { AppState } from "../appState";
|
import type { AppState } from "../appState";
|
||||||
import type { Workspace } from "../api";
|
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 idPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||||
const localIdPattern = /^[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[] {
|
getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] {
|
||||||
const context = { state, workspace };
|
const context = { machine: pluginMachineFromState(state), state, workspace };
|
||||||
return [...this.workspaceLabels]
|
return [...this.workspaceLabels]
|
||||||
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
|
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
|
||||||
.flatMap((contribution) => {
|
.flatMap((contribution) => {
|
||||||
@@ -89,11 +89,13 @@ export class PluginRegistry {
|
|||||||
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
|
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
|
||||||
const id = this.qualify(pluginId, panel.id);
|
const id = this.qualify(pluginId, panel.id);
|
||||||
const badge = panel.badge;
|
const badge = panel.badge;
|
||||||
|
const visible = panel.visible;
|
||||||
return {
|
return {
|
||||||
...panel,
|
...panel,
|
||||||
id,
|
id,
|
||||||
pluginId,
|
pluginId,
|
||||||
localId: panel.id,
|
localId: panel.id,
|
||||||
|
...(visible === undefined ? {} : { visible: (context: WorkspacePanelContext) => visible(workspacePanelContextFor(context, pluginId)) }),
|
||||||
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
|
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
|
||||||
render: (context: WorkspacePanelContext) => panel.render(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);
|
workspacePanelScopes.set(context, scope);
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): PluginMachine {
|
||||||
|
const machine = state.selectedMachine;
|
||||||
|
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
||||||
|
return { id: "local", name: "local", kind: "local" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import type { AppAction } from "../actions";
|
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 { AppState } from "../appState";
|
||||||
import type { SettingsSection } from "../settingsRoute";
|
import type { SettingsSection } from "../settingsRoute";
|
||||||
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
|
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
|
||||||
@@ -39,7 +39,24 @@ export interface PluginContributions {
|
|||||||
themePairs?: ThemePairContribution[];
|
themePairs?: ThemePairContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PiWebInternalRuntimeContext {
|
export interface PluginMachine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: Machine["kind"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePanelFiles {
|
||||||
|
readFile(path: string): Promise<FileContentResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceTerminalCommandInput = Omit<RunTerminalCommandInput, "workspace">;
|
||||||
|
|
||||||
|
export interface WorkspacePanelTerminal {
|
||||||
|
open(options?: { terminalId?: string | undefined }): void;
|
||||||
|
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiWebUnstableRuntimeContext {
|
||||||
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
|
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
|
||||||
openSettings?: (section?: SettingsSection) => void;
|
openSettings?: (section?: SettingsSection) => void;
|
||||||
}
|
}
|
||||||
@@ -53,7 +70,7 @@ export interface TerminalCommandRunsInternalRuntime {
|
|||||||
|
|
||||||
export interface PluginRuntimeContext {
|
export interface PluginRuntimeContext {
|
||||||
state: AppState;
|
state: AppState;
|
||||||
piWebInternal?: PiWebInternalRuntimeContext;
|
piWebUnstable?: PiWebUnstableRuntimeContext;
|
||||||
openActionPalette: () => void;
|
openActionPalette: () => void;
|
||||||
focusPrompt: () => void;
|
focusPrompt: () => void;
|
||||||
addProject: () => void | Promise<void>;
|
addProject: () => void | Promise<void>;
|
||||||
@@ -93,15 +110,14 @@ export interface QualifiedPluginAction extends AppAction {
|
|||||||
localId: LocalContributionId;
|
localId: LocalContributionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspacePanelVisibilityContext {
|
|
||||||
workspace: Workspace;
|
|
||||||
state: AppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspacePanelContext {
|
export interface WorkspacePanelContext {
|
||||||
|
machine: PluginMachine;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
state: AppState;
|
state: AppState;
|
||||||
piWebInternal?: PiWebInternalRuntimeContext;
|
files: WorkspacePanelFiles;
|
||||||
|
terminal: WorkspacePanelTerminal;
|
||||||
|
requestRender: () => void;
|
||||||
|
piWebUnstable?: Pick<PiWebUnstableRuntimeContext, "terminalCommandRuns">;
|
||||||
fileTree: FileTreeEntry[];
|
fileTree: FileTreeEntry[];
|
||||||
expandedDirs: Record<string, FileTreeEntry[]>;
|
expandedDirs: Record<string, FileTreeEntry[]>;
|
||||||
selectedFilePath: string | undefined;
|
selectedFilePath: string | undefined;
|
||||||
@@ -131,7 +147,7 @@ export interface WorkspacePanelContribution {
|
|||||||
title: string;
|
title: string;
|
||||||
icon?: WorkspacePanelIcon;
|
icon?: WorkspacePanelIcon;
|
||||||
order?: number;
|
order?: number;
|
||||||
visible?: (context: WorkspacePanelVisibilityContext) => boolean;
|
visible?: (context: WorkspacePanelContext) => boolean;
|
||||||
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
|
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
|
||||||
render: (context: WorkspacePanelContext) => TemplateResult;
|
render: (context: WorkspacePanelContext) => TemplateResult;
|
||||||
}
|
}
|
||||||
@@ -143,6 +159,7 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceLabelContext {
|
export interface WorkspaceLabelContext {
|
||||||
|
machine: PluginMachine;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
state: AppState;
|
state: AppState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
configureAuth: () => void | Promise<void>;
|
||||||
|
logoutAuth: () => void | Promise<void>;
|
||||||
|
openThemePicker: () => void;
|
||||||
|
selectMainView: (view: string) => void;
|
||||||
|
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
|
||||||
|
openTerminal: (options?: { terminalId?: string | undefined }) => void;
|
||||||
|
refreshFiles: () => void | Promise<void>;
|
||||||
|
refreshGit: () => void | Promise<void>;
|
||||||
|
refreshAppData: () => void | Promise<void>;
|
||||||
|
reloadPage: () => void;
|
||||||
|
startSession: () => void | Promise<void>;
|
||||||
|
archiveSession: () => void | Promise<void>;
|
||||||
|
stopActiveWork: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAction {
|
||||||
|
id: LocalContributionId;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
group?: string;
|
||||||
|
enabled?: (context: PluginRuntimeContext) => boolean;
|
||||||
|
run: (context: PluginRuntimeContext) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FileContentResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTerminalCommandInput {
|
||||||
|
title: string;
|
||||||
|
command: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePanelTerminal {
|
||||||
|
open(options?: { terminalId?: string | undefined }): void;
|
||||||
|
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<TerminalCommandRunHandle>;
|
||||||
|
listCommandRuns(filter?: TerminalCommandRunFilter): Promise<TerminalCommandRun[]>;
|
||||||
|
getCommandRun(runId: string): Promise<TerminalCommandRun | undefined>;
|
||||||
|
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<UnstableRuntimeCapabilities, "terminalCommandRuns">;
|
||||||
|
}
|
||||||
+3
-3
@@ -27,7 +27,8 @@
|
|||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"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": [
|
"include": [
|
||||||
@@ -35,7 +36,6 @@
|
|||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"extensions/**/*.ts",
|
"extensions/**/*.ts",
|
||||||
"pi-web-plugins/**/*.ts",
|
"pi-web-plugins/**/*.ts"
|
||||||
"plugin-api.d.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user