Archived
feat(plugins): add actions plugin package
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
"@jmfederico/pi-web-actions": patch
|
||||
---
|
||||
|
||||
Document and harden separate Pi Web plugin package development, including the Actions plugin refresh flow and private API dogfooding notes.
|
||||
@@ -127,6 +127,29 @@ ln -s /path/to/plugin-folder ~/.pi-web/plugins/plugin-id
|
||||
|
||||
Reload the Pi Web browser tab. Pi Web serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes.
|
||||
|
||||
## First-party separate plugin packages
|
||||
|
||||
First-party plugins that are published as their own npm packages can live in this repository under `plugins/*` as npm workspaces. These packages are **not bundled** into the main `@jmfederico/pi-web` npm package automatically; they are separate packages that share CI, tests, and local development tooling with the main repo.
|
||||
|
||||
A separate plugin package should:
|
||||
|
||||
- use type-only imports from `@jmfederico/pi-web/plugin-api` when it needs shared Pi Web plugin interfaces; this subpath is currently a `.d.ts`-only dogfooding surface, not a runtime JavaScript module;
|
||||
- keep its Pi Web metadata in its own `package.json` with `piWeb.plugins` entries pointing at built JavaScript in `dist/`;
|
||||
- include a package-level `build` script and `prepack` script so `npm pack --workspace <package>` and `npm publish --workspace <package>` produce a usable plugin package;
|
||||
- use a local symlink into `~/.pi-web/plugins/<plugin-id>` while developing;
|
||||
- document any private Pi Web APIs it dogfoods until those APIs become stable plugin runtime helpers.
|
||||
|
||||
Typical local development loop:
|
||||
|
||||
```bash
|
||||
npm --workspace @jmfederico/pi-web-actions run dev
|
||||
mkdir -p ~/.pi-web/plugins
|
||||
ln -s /path/to/pi-web/plugins/actions ~/.pi-web/plugins/actions
|
||||
curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
|
||||
```
|
||||
|
||||
The main Pi Web `dev:web` script watches bundled plugins in `pi-web-plugins/`. Separate workspace packages should run their own package-level watcher when needed.
|
||||
|
||||
## Discovery and packaging
|
||||
|
||||
Pi Web builds `/pi-web-plugins/manifest.json` from these sources:
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ import tseslint from "typescript-eslint";
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**"],
|
||||
ignores: ["dist/**", "node_modules/**", "plugins/*/dist/**"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.ts", "extensions/**/*.ts", "pi-web-plugins/**/*.ts", "vite.config.ts", "vitest.config.ts"],
|
||||
files: ["src/**/*.ts", "extensions/**/*.ts", "pi-web-plugins/**/*.ts", "plugins/**/*.ts", "vite.config.ts", "vitest.config.ts"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
|
||||
Generated
+29
@@ -8,6 +8,10 @@
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202605.10",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
".",
|
||||
"plugins/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
@@ -2322,6 +2326,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jmfederico/pi-web": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jmfederico/pi-web-actions": {
|
||||
"resolved": "plugins/actions",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -8667,6 +8679,23 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"plugins/actions": {
|
||||
"name": "@jmfederico/pi-web-actions",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jmfederico/pi-web": ">=1.202605.10 <2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@jmfederico/pi-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -5,6 +5,10 @@
|
||||
"license": "MIT",
|
||||
"author": "Federico Jaramillo Martinez",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
".",
|
||||
"plugins/*"
|
||||
],
|
||||
"bin": {
|
||||
"pi-web": "dist/cli.js",
|
||||
"pi-web-server": "dist/server/index.js",
|
||||
@@ -17,7 +21,8 @@
|
||||
"LICENSE",
|
||||
"extensions",
|
||||
"docs/plugins.md",
|
||||
"docs/assets"
|
||||
"docs/assets",
|
||||
"plugin-api.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'",
|
||||
@@ -26,15 +31,16 @@
|
||||
"dev:server": "npm run dev:web",
|
||||
"dev:client": "vite --host 0.0.0.0",
|
||||
"dev:plugins": "node scripts/build-plugins.mjs --watch",
|
||||
"build": "tsc -p tsconfig.build.json && npm run build:plugins && vite build",
|
||||
"build": "tsc -p tsconfig.build.json && npm run build:plugins && npm run build:plugin-packages && vite build",
|
||||
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
|
||||
"build:plugin-packages": "bash -c 'set -e; shopt -s nullglob; for package in plugins/*/package.json; do dir=${package%/package.json}; (cd \"$dir\" && npm run build --if-present); done'",
|
||||
"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\" \"plugins/**/*.ts\" vite.config.ts vitest.config.ts",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"verify": "npm run typecheck && npm run lint && npm test",
|
||||
"start": "tsx src/server/index.ts",
|
||||
"start:sessiond": "tsx src/server/sessiond.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"clean": "rm -rf dist plugins/*/dist",
|
||||
"prepack": "npm run build",
|
||||
"pack:dry": "npm pack --dry-run",
|
||||
"prepublishOnly": "npm run verify",
|
||||
|
||||
Vendored
+146
@@ -0,0 +1,146 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
|
||||
export type PluginId = string;
|
||||
export type LocalContributionId = string;
|
||||
export type QualifiedContributionId = string;
|
||||
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
|
||||
|
||||
export interface PiWebPlugin {
|
||||
apiVersion: 1;
|
||||
name: string;
|
||||
activate: (context: PluginActivationContext) => PluginActivationResult;
|
||||
}
|
||||
|
||||
export interface PluginActivationContext {
|
||||
apiVersion: 1;
|
||||
pluginId: PluginId;
|
||||
html: HtmlTemplateTag;
|
||||
}
|
||||
|
||||
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>;
|
||||
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 interface WorkspacePanelContribution {
|
||||
id: LocalContributionId;
|
||||
title: string;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# Pi Web Actions
|
||||
|
||||
Configurable workspace actions for Pi Web.
|
||||
|
||||
The plugin adds an **Actions** workspace tab. Actions create a new Pi Web terminal, send the configured shell command, and switch to the Terminal tab so the user can monitor progress or take over.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `.pi-web/actions.json` in the workspace root:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"actions": [
|
||||
{
|
||||
"id": "docker.start",
|
||||
"title": "Start Docker",
|
||||
"group": "Docker",
|
||||
"description": "Start the local Docker Compose environment.",
|
||||
"command": "./docker/scripts/docker-compose-dev up -d"
|
||||
},
|
||||
{
|
||||
"id": "db.reset",
|
||||
"title": "Reset DB",
|
||||
"group": "Database",
|
||||
"command": "go -C klingit-go run ./cli db reset",
|
||||
"confirm": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `version`: must be `1`.
|
||||
- `actions`: array of action definitions.
|
||||
- `id`: stable action id, matching `^[a-z][a-z0-9.-]*$`.
|
||||
- `title`: button label.
|
||||
- `command`: literal shell command sent to the terminal.
|
||||
- `description`: optional explanatory text.
|
||||
- `group`: optional group heading.
|
||||
- `confirm`: optional boolean. When true, the browser asks before dispatching the command.
|
||||
|
||||
Commands run in the workspace root because Pi Web creates the terminal for that workspace.
|
||||
|
||||
After editing `.pi-web/actions.json`, click **Refresh** in the Actions tab or reload the browser tab. The plugin does not watch the file automatically.
|
||||
|
||||
## Development in this monorepo
|
||||
|
||||
This package is developed as a separate npm package, not as a bundled Pi Web plugin. For local development:
|
||||
|
||||
```bash
|
||||
npm --workspace @jmfederico/pi-web-actions run dev
|
||||
mkdir -p ~/.pi-web/plugins
|
||||
ln -s /srv/dev/pi-web/plugins/actions ~/.pi-web/plugins/actions
|
||||
```
|
||||
|
||||
Then reload Pi Web and check discovery:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
|
||||
```
|
||||
|
||||
Build the package before publishing or packing:
|
||||
|
||||
```bash
|
||||
npm --workspace @jmfederico/pi-web-actions run build
|
||||
npm pack --workspace @jmfederico/pi-web-actions --dry-run
|
||||
```
|
||||
|
||||
## Beta/private API note
|
||||
|
||||
This plugin intentionally dogfoods private Pi Web browser APIs for reading workspace files and creating/writing terminals. Those APIs are not yet stable public plugin APIs, so compatibility is best-effort and may require updates alongside Pi Web releases.
|
||||
|
||||
## Notes
|
||||
|
||||
This plugin intentionally keeps v1 simple:
|
||||
|
||||
- static JSON only;
|
||||
- no variables or templating;
|
||||
- every action creates a new terminal;
|
||||
- command prompting/extra input should be handled by the script itself.
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@jmfederico/pi-web-actions",
|
||||
"version": "0.1.0",
|
||||
"description": "Configurable workspace actions plugin for Pi Web.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsc -w -p tsconfig.json",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run --config ../../vitest.config.ts",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-web",
|
||||
"pi-web-plugin",
|
||||
"actions",
|
||||
"workspace"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@jmfederico/pi-web": ">=1.202605.10 <2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@jmfederico/pi-web": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"piWeb": {
|
||||
"plugins": [
|
||||
{
|
||||
"id": "actions",
|
||||
"module": "dist/pi-web-plugin.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js";
|
||||
import { createWorkspaceTerminal, sendTerminalCommand } from "./terminalDispatcher.js";
|
||||
import { openTerminalPanel, requestPiWebRender } from "./piWebPrivateUi.js";
|
||||
import { loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js";
|
||||
|
||||
export const actionsPanelTagName = "pi-web-actions-panel";
|
||||
|
||||
export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void;
|
||||
|
||||
const configChangedEvent = "pi-web-actions-config-changed";
|
||||
|
||||
type ConfigState =
|
||||
| { kind: "loading" }
|
||||
| WorkspaceActionsConfigLoadResult;
|
||||
|
||||
const configCache = new Map<string, ConfigState>();
|
||||
|
||||
export function defineActionsPanelElement(): void {
|
||||
if (!customElements.get(actionsPanelTagName)) customElements.define(actionsPanelTagName, PiWebActionsPanel);
|
||||
}
|
||||
|
||||
export function actionsPanelBadge(workspace: Workspace): string | number | undefined {
|
||||
const state = getCachedWorkspaceConfig(workspace);
|
||||
if (state?.kind === "unavailable") return "!";
|
||||
if (state?.kind === "loaded" && state.config.actions.length > 0) return state.config.actions.length;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class PiWebActionsPanel extends HTMLElement {
|
||||
private workspaceValue: Workspace | undefined;
|
||||
private openTerminalValue: OpenTerminal | undefined;
|
||||
private runningActionId: string | undefined;
|
||||
private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined;
|
||||
private readonly root: ShadowRoot;
|
||||
private readonly onConfigChanged = () => {
|
||||
this.render();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.root = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
set workspace(value: Workspace | undefined) {
|
||||
this.workspaceValue = value;
|
||||
this.render();
|
||||
}
|
||||
|
||||
set openTerminal(value: OpenTerminal | undefined) {
|
||||
this.openTerminalValue = value;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
window.addEventListener(configChangedEvent, this.onConfigChanged);
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
window.removeEventListener(configChangedEvent, this.onConfigChanged);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const workspace = this.workspaceValue;
|
||||
if (workspace === undefined) {
|
||||
this.root.innerHTML = `${actionStyles()}<section class="empty">Select a workspace.</section>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrLoadWorkspaceConfig(workspace);
|
||||
this.root.innerHTML = `
|
||||
${actionStyles()}
|
||||
<section class="toolbar">
|
||||
<strong>Workspace Actions</strong>
|
||||
<span class="toolbar-actions">
|
||||
<button class="secondary" data-refresh-config ${state.kind === "loading" ? "disabled" : ""}>Refresh</button>
|
||||
<button class="secondary" data-open-terminal>Open Terminal</button>
|
||||
</span>
|
||||
</section>
|
||||
<section class="viewer actions-viewer">
|
||||
${this.renderConfigState(state)}
|
||||
</section>
|
||||
`;
|
||||
|
||||
this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
|
||||
void this.refreshConfig(workspace);
|
||||
});
|
||||
|
||||
for (const button of this.root.querySelectorAll("button[data-action-id]")) {
|
||||
button.addEventListener("click", () => {
|
||||
const action = actionFromConfigState(state, button.getAttribute("data-action-id"));
|
||||
if (action !== undefined) void this.dispatchAction(workspace, action);
|
||||
});
|
||||
}
|
||||
|
||||
this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => {
|
||||
this.openWorkspaceTerminal(workspace);
|
||||
});
|
||||
}
|
||||
|
||||
private renderConfigState(state: ConfigState): string {
|
||||
if (state.kind === "loading") return `<p class="muted">Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…</p>${this.renderStatus()}`;
|
||||
if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`;
|
||||
if (state.config.actions.length === 0) return `<p class="muted">No actions configured in ${escapeHtml(ACTIONS_CONFIG_PATH)}.</p>${this.renderStatus()}`;
|
||||
return `
|
||||
<p class="muted">Actions create a new workspace terminal, send the command, then switch to the Terminal tab. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
|
||||
${renderActionGroups(state.config.actions, this.runningActionId)}
|
||||
${this.renderStatus()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatus(): string {
|
||||
if (this.status === undefined) return "";
|
||||
const detail = this.status.detail === undefined ? "" : `<pre>${escapeHtml(this.status.detail)}</pre>`;
|
||||
return `<div class="status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
|
||||
}
|
||||
|
||||
private async refreshConfig(workspace: Workspace): Promise<void> {
|
||||
this.status = { kind: "info", message: `Refreshing ${ACTIONS_CONFIG_PATH}…` };
|
||||
configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" });
|
||||
this.render();
|
||||
|
||||
const state = await refreshWorkspaceConfig(workspace);
|
||||
this.status = state.kind === "loaded"
|
||||
? { kind: "success", message: `Loaded ${String(state.config.actions.length)} action${state.config.actions.length === 1 ? "" : "s"}.` }
|
||||
: undefined;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise<void> {
|
||||
if (this.runningActionId !== undefined) return;
|
||||
if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return;
|
||||
|
||||
this.runningActionId = action.id;
|
||||
this.status = { kind: "info", message: `Creating terminal for ${action.title}…` };
|
||||
this.render();
|
||||
|
||||
try {
|
||||
const terminal = await createWorkspaceTerminal(workspace, action.title);
|
||||
this.status = { kind: "info", message: `Dispatching command to ${terminal.name}…` };
|
||||
this.render();
|
||||
|
||||
await sendTerminalCommand(workspace, terminal.id, action.command);
|
||||
this.status = {
|
||||
kind: "success",
|
||||
message: `Dispatched to terminal “${terminal.name}”.`,
|
||||
detail: action.command,
|
||||
};
|
||||
this.runningActionId = undefined;
|
||||
this.render();
|
||||
this.openWorkspaceTerminal(workspace, terminal.id);
|
||||
} catch (error) {
|
||||
this.runningActionId = undefined;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private openWorkspaceTerminal(workspace: Workspace, terminalId?: string): void {
|
||||
if (this.openTerminalValue !== undefined) {
|
||||
if (terminalId === undefined) this.openTerminalValue();
|
||||
else this.openTerminalValue({ terminalId });
|
||||
return;
|
||||
}
|
||||
openTerminalPanel(workspace, terminalId);
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined {
|
||||
return configCache.get(cacheKeyForWorkspace(workspace));
|
||||
}
|
||||
|
||||
function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState {
|
||||
const cached = getCachedWorkspaceConfig(workspace);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const loading: ConfigState = { kind: "loading" };
|
||||
configCache.set(cacheKeyForWorkspace(workspace), loading);
|
||||
void refreshWorkspaceConfig(workspace);
|
||||
return loading;
|
||||
}
|
||||
|
||||
async function refreshWorkspaceConfig(workspace: Workspace): Promise<ConfigState> {
|
||||
const key = cacheKeyForWorkspace(workspace);
|
||||
const state = await loadWorkspaceActionsConfig(workspace).catch((error: unknown): ConfigState => ({
|
||||
kind: "unavailable",
|
||||
message: `No valid ${ACTIONS_CONFIG_PATH} found.`,
|
||||
hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
configCache.set(key, state);
|
||||
requestPiWebRender();
|
||||
window.dispatchEvent(new Event(configChangedEvent));
|
||||
return state;
|
||||
}
|
||||
|
||||
function cacheKeyForWorkspace(workspace: Workspace): string {
|
||||
return `${workspace.projectId}:${workspace.id}`;
|
||||
}
|
||||
|
||||
function renderUnavailableState(state: Extract<ConfigState, { kind: "unavailable" }>): string {
|
||||
const detail = state.detail === undefined ? "" : `<pre>${escapeHtml(state.detail)}</pre>`;
|
||||
return `<div class="status error"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p>${detail}</div>`;
|
||||
}
|
||||
|
||||
function renderActionGroups(actions: WorkspaceAction[], runningActionId: string | undefined): string {
|
||||
return `<div class="actions">${groupActions(actions).map((group) => renderActionGroup(group, runningActionId)).join("")}</div>`;
|
||||
}
|
||||
|
||||
function groupActions(actions: WorkspaceAction[]): { title: string | undefined; actions: WorkspaceAction[] }[] {
|
||||
const groups: { title: string | undefined; actions: WorkspaceAction[] }[] = [];
|
||||
for (const action of actions) {
|
||||
const title = action.group;
|
||||
let group = groups.find((candidate) => candidate.title === title);
|
||||
if (group === undefined) {
|
||||
group = { title, actions: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.actions.push(action);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderActionGroup(group: { title: string | undefined; actions: WorkspaceAction[] }, runningActionId: string | undefined): string {
|
||||
const title = group.title === undefined ? "" : `<h3>${escapeHtml(group.title)}</h3>`;
|
||||
return `<section class="action-group">${title}${group.actions.map((action) => renderAction(action, runningActionId)).join("")}</section>`;
|
||||
}
|
||||
|
||||
function renderAction(action: WorkspaceAction, runningActionId: string | undefined): string {
|
||||
const running = runningActionId === action.id;
|
||||
const disabled = runningActionId !== undefined;
|
||||
const description = action.description === undefined ? "" : `<span>${escapeHtml(action.description)}</span>`;
|
||||
return `
|
||||
<article class="action-card">
|
||||
<div class="action-copy">
|
||||
<strong>${escapeHtml(action.title)}</strong>
|
||||
${description}
|
||||
<code>${escapeHtml(action.command)}</code>
|
||||
</div>
|
||||
<button data-action-id="${escapeAttr(action.id)}" ${disabled ? "disabled" : ""}>${running ? "Dispatching…" : "Run"}</button>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function actionFromConfigState(state: ConfigState, actionId: string | null): WorkspaceAction | undefined {
|
||||
if (state.kind !== "loaded" || actionId === null) return undefined;
|
||||
return state.config.actions.find((action) => action.id === actionId);
|
||||
}
|
||||
|
||||
function actionStyles(): string {
|
||||
return `
|
||||
<style>
|
||||
:host { display: contents; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
.toolbar-actions { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.viewer { box-sizing: border-box; min-height: 0; overflow: auto; padding: 12px; }
|
||||
.actions-viewer { display: grid; align-content: start; gap: 12px; }
|
||||
.actions { display: grid; gap: 14px; }
|
||||
.action-group { display: grid; gap: 10px; }
|
||||
.action-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.action-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.action-copy { display: grid; min-width: 0; gap: 5px; }
|
||||
.action-copy span, .muted { color: var(--pi-muted); }
|
||||
code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
code { overflow: auto; padding: 5px 7px; white-space: nowrap; }
|
||||
pre { margin: 8px 0 0; overflow: auto; padding: 8px; white-space: pre-wrap; }
|
||||
button { border: 1px solid var(--pi-accent-border); border-radius: 7px; background: var(--pi-accent); color: var(--pi-bg); cursor: pointer; padding: 6px 10px; font: inherit; }
|
||||
button.secondary { border-color: var(--pi-border); background: var(--pi-surface); color: var(--pi-text); }
|
||||
button:disabled { cursor: wait; opacity: 0.65; }
|
||||
.status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
|
||||
.status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
|
||||
.status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); }
|
||||
.status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
|
||||
.empty { padding: 16px; color: var(--pi-muted); }
|
||||
@media (max-width: 760px) {
|
||||
.action-card { grid-template-columns: 1fr; }
|
||||
.action-card button { justify-self: start; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function escapeAttr(value: unknown): string {
|
||||
return escapeHtml(value).replaceAll('"', """);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseActionsConfigText } from "./config";
|
||||
|
||||
describe("workspace actions config", () => {
|
||||
it("parses a minimal version 1 config", () => {
|
||||
expect(parseActionsConfigText(JSON.stringify({
|
||||
version: 1,
|
||||
actions: [
|
||||
{ id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset" },
|
||||
],
|
||||
}))).toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
version: 1,
|
||||
actions: [
|
||||
{ id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset", confirm: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses optional group, description, and confirm fields", () => {
|
||||
expect(parseActionsConfigText(JSON.stringify({
|
||||
version: 1,
|
||||
actions: [
|
||||
{
|
||||
id: "docker.start",
|
||||
title: "Start Docker",
|
||||
description: "Start the dev stack.",
|
||||
group: "Docker",
|
||||
command: "./docker/scripts/docker-compose-dev up -d",
|
||||
confirm: true,
|
||||
},
|
||||
],
|
||||
}))).toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
version: 1,
|
||||
actions: [
|
||||
{
|
||||
id: "docker.start",
|
||||
title: "Start Docker",
|
||||
description: "Start the dev stack.",
|
||||
group: "Docker",
|
||||
command: "./docker/scripts/docker-compose-dev up -d",
|
||||
confirm: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an empty actions array", () => {
|
||||
expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [] }))).toEqual({
|
||||
ok: true,
|
||||
config: { version: 1, actions: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid JSON and unsupported versions", () => {
|
||||
expect(parseActionsConfigText("{")).toMatchObject({ ok: false });
|
||||
expect(parseActionsConfigText(JSON.stringify({ version: 2, actions: [] }))).toEqual({
|
||||
ok: false,
|
||||
error: "Config version must be 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing, empty, or duplicate required fields", () => {
|
||||
expect(parseActionsConfigText(JSON.stringify({ version: 1 }))).toEqual({
|
||||
ok: false,
|
||||
error: "Config actions must be an array",
|
||||
});
|
||||
expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "", title: "T", command: "cmd" }] }))).toEqual({
|
||||
ok: false,
|
||||
error: "Action 1 id must be a non-empty string",
|
||||
});
|
||||
expect(parseActionsConfigText(JSON.stringify({
|
||||
version: 1,
|
||||
actions: [
|
||||
{ id: "one", title: "One", command: "cmd" },
|
||||
{ id: "one", title: "Again", command: "cmd" },
|
||||
],
|
||||
}))).toEqual({
|
||||
ok: false,
|
||||
error: "Duplicate action id: one",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid optional field types", () => {
|
||||
expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", confirm: "yes" }] }))).toEqual({
|
||||
ok: false,
|
||||
error: "Action 1 confirm must be a boolean",
|
||||
});
|
||||
expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", group: "" }] }))).toEqual({
|
||||
ok: false,
|
||||
error: "Action 1 group must be a non-empty string when provided",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
export const ACTIONS_CONFIG_PATH = ".pi-web/actions.json";
|
||||
export const ACTIONS_CONFIG_VERSION = 1;
|
||||
|
||||
const actionIdPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||
|
||||
export interface WorkspaceActionsConfig {
|
||||
version: typeof ACTIONS_CONFIG_VERSION;
|
||||
actions: WorkspaceAction[];
|
||||
}
|
||||
|
||||
export interface WorkspaceAction {
|
||||
id: string;
|
||||
title: string;
|
||||
command: string;
|
||||
description?: string;
|
||||
group?: string;
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
export type ParseActionsConfigResult =
|
||||
| { ok: true; config: WorkspaceActionsConfig }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function parseActionsConfigText(text: string): ParseActionsConfigResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
return { ok: false, error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
return parseActionsConfig(parsed);
|
||||
}
|
||||
|
||||
export function parseActionsConfig(value: unknown): ParseActionsConfigResult {
|
||||
if (!isRecord(value)) return invalid("Config must be an object");
|
||||
if (value["version"] !== ACTIONS_CONFIG_VERSION) return invalid("Config version must be 1");
|
||||
|
||||
const actions = value["actions"];
|
||||
if (!Array.isArray(actions)) return invalid("Config actions must be an array");
|
||||
|
||||
const ids = new Set<string>();
|
||||
const parsedActions: WorkspaceAction[] = [];
|
||||
for (const [index, action] of actions.entries()) {
|
||||
const parsedAction = parseAction(action, index);
|
||||
if (!parsedAction.ok) return parsedAction;
|
||||
if (ids.has(parsedAction.action.id)) return invalid(`Duplicate action id: ${parsedAction.action.id}`);
|
||||
ids.add(parsedAction.action.id);
|
||||
parsedActions.push(parsedAction.action);
|
||||
}
|
||||
|
||||
return { ok: true, config: { version: ACTIONS_CONFIG_VERSION, actions: parsedActions } };
|
||||
}
|
||||
|
||||
type ParseActionResult =
|
||||
| { ok: true; action: WorkspaceAction }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function parseAction(value: unknown, index: number): ParseActionResult {
|
||||
const label = `Action ${String(index + 1)}`;
|
||||
if (!isRecord(value)) return invalid(`${label} must be an object`);
|
||||
|
||||
const id = requireNonEmptyString(value, "id", label);
|
||||
if (!id.ok) return id;
|
||||
if (!actionIdPattern.test(id.value)) return invalid(`${label} id must match ${actionIdPattern.source}`);
|
||||
|
||||
const title = requireNonEmptyString(value, "title", label);
|
||||
if (!title.ok) return title;
|
||||
|
||||
const command = requireNonEmptyString(value, "command", label);
|
||||
if (!command.ok) return command;
|
||||
|
||||
const description = optionalNonEmptyString(value, "description", label);
|
||||
if (!description.ok) return description;
|
||||
|
||||
const group = optionalNonEmptyString(value, "group", label);
|
||||
if (!group.ok) return group;
|
||||
|
||||
const confirm = value["confirm"];
|
||||
if (confirm !== undefined && typeof confirm !== "boolean") return invalid(`${label} confirm must be a boolean`);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: {
|
||||
id: id.value,
|
||||
title: title.value,
|
||||
command: command.value,
|
||||
...(description.value === undefined ? {} : { description: description.value }),
|
||||
...(group.value === undefined ? {} : { group: group.value }),
|
||||
confirm: confirm ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type StringFieldResult =
|
||||
| { ok: true; value: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type OptionalStringFieldResult =
|
||||
| { ok: true; value: string | undefined }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function requireNonEmptyString(record: Record<string, unknown>, key: string, label: string): StringFieldResult {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim() === "") return invalid(`${label} ${key} must be a non-empty string`);
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function optionalNonEmptyString(record: Record<string, unknown>, key: string, label: string): OptionalStringFieldResult {
|
||||
const value = record[key];
|
||||
if (value === undefined) return { ok: true, value: undefined };
|
||||
if (typeof value !== "string" || value.trim() === "") return invalid(`${label} ${key} must be a non-empty string when provided`);
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function invalid(error: string): { ok: false; error: string } {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
|
||||
import { ACTIONS_CONFIG_PATH } from "./config.js";
|
||||
import { actionsPanelBadge, defineActionsPanelElement } from "./actionsPanelElement.js";
|
||||
|
||||
const plugin: PiWebPlugin = {
|
||||
apiVersion: 1,
|
||||
name: "Workspace Actions",
|
||||
activate: ({ pluginId, html }) => {
|
||||
defineActionsPanelElement();
|
||||
|
||||
return {
|
||||
contributions: {
|
||||
actions: [
|
||||
{
|
||||
id: "workspace.open-actions",
|
||||
title: "Open Workspace Actions",
|
||||
description: `Open the workspace Actions tab. Configure actions in ${ACTIONS_CONFIG_PATH}.`,
|
||||
group: "Workspace",
|
||||
enabled: (context) => context.state.selectedWorkspace !== undefined,
|
||||
run: (context) => {
|
||||
if (context.state.selectedWorkspace === undefined) return;
|
||||
context.selectWorkspaceTool(`${pluginId}:workspace.actions`);
|
||||
},
|
||||
},
|
||||
],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "workspace.actions",
|
||||
title: "Actions",
|
||||
order: 40,
|
||||
badge: ({ workspace }) => actionsPanelBadge(workspace),
|
||||
render: ({ workspace, openTerminal }) => html`<pi-web-actions-panel .workspace=${workspace} .openTerminal=${openTerminal}></pi-web-actions-panel>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import { terminalToolId, type TerminalInfo } from "./terminalDispatcher.js";
|
||||
|
||||
interface TerminalPanelElement {
|
||||
terminals: TerminalInfo[];
|
||||
selectTerminal: (terminalId: string) => void;
|
||||
}
|
||||
|
||||
interface Updatable {
|
||||
requestUpdate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private Pi Web UI fallback used while the plugin API is still being dogfooded.
|
||||
* The current host provides a panel `openTerminal` helper; keep this fallback contained
|
||||
* for older hosts and replace/remove it once the public helper is required.
|
||||
*/
|
||||
export function openTerminalPanel(workspace: Workspace, terminalId?: string): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("project", workspace.projectId);
|
||||
url.searchParams.set("workspace", workspace.id);
|
||||
url.searchParams.set("tool", terminalToolId);
|
||||
url.searchParams.set("view", terminalToolId);
|
||||
window.history.pushState({}, "", url);
|
||||
dispatchPopState();
|
||||
if (terminalId !== undefined) selectTerminalWhenAvailable(terminalId);
|
||||
}
|
||||
|
||||
export function requestPiWebRender(): void {
|
||||
const app = document.querySelector("pi-web-app");
|
||||
if (isUpdatable(app)) app.requestUpdate();
|
||||
}
|
||||
|
||||
function dispatchPopState(): void {
|
||||
if (typeof PopStateEvent === "function") {
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
}
|
||||
|
||||
function selectTerminalWhenAvailable(terminalId: string, attempt = 0): void {
|
||||
const terminalPanel = findTerminalPanel();
|
||||
const terminals = terminalPanel?.terminals ?? [];
|
||||
const hasTerminal = terminals.some((terminal) => terminal.id === terminalId);
|
||||
|
||||
if (terminalPanel !== undefined && hasTerminal) {
|
||||
terminalPanel.selectTerminal(terminalId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < 50) window.setTimeout(() => { selectTerminalWhenAvailable(terminalId, attempt + 1); }, 150);
|
||||
}
|
||||
|
||||
function findTerminalPanel(): TerminalPanelElement | undefined {
|
||||
const panel = document.querySelector("workspace-panel")?.shadowRoot?.querySelector("terminal-panel");
|
||||
return isTerminalPanelElement(panel) ? panel : undefined;
|
||||
}
|
||||
|
||||
function isTerminalPanelElement(value: unknown): value is TerminalPanelElement {
|
||||
return isRecord(value) && Array.isArray(value["terminals"]) && typeof value["selectTerminal"] === "function";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import { actionTerminalCols, actionTerminalRows, createWorkspaceTerminal, normalizeTerminalCommand, parseTerminalInfo, parseTerminalSocketMessage, terminalSocketUrl, type FetchLike } from "./terminalDispatcher";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "workspace 1",
|
||||
projectId: "project/1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: false,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
describe("terminal dispatcher", () => {
|
||||
it("normalizes commands for terminal input", () => {
|
||||
expect(normalizeTerminalCommand("npm test")).toBe("npm test\r");
|
||||
expect(normalizeTerminalCommand("npm test\n")).toBe("npm test\r");
|
||||
expect(normalizeTerminalCommand("npm test\r\n")).toBe("npm test\r");
|
||||
});
|
||||
|
||||
it("builds terminal socket URLs from the current host", () => {
|
||||
expect(terminalSocketUrl(workspace, "term/1", { protocol: "https:", host: "example.test" })).toBe(
|
||||
`wss://example.test/api/projects/project%2F1/workspaces/workspace%201/terminals/term%2F1/socket?cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses terminal socket messages", () => {
|
||||
expect(parseTerminalSocketMessage(JSON.stringify({ type: "error", message: "boom" }))).toEqual({ type: "error", message: "boom" });
|
||||
expect(parseTerminalSocketMessage(JSON.stringify({ type: "output", data: "hello" }))).toEqual({ type: "output" });
|
||||
expect(parseTerminalSocketMessage("not json")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates terminals through the private workspace terminal endpoint", async () => {
|
||||
let capturedRequest: { input: string; init: RequestInit | undefined } | undefined;
|
||||
const fetcher: FetchLike = (input, init) => {
|
||||
capturedRequest = { input, init };
|
||||
return Promise.resolve(new Response(JSON.stringify({ id: "t1", name: "Action: Build" }), { status: 200 }));
|
||||
};
|
||||
|
||||
await expect(createWorkspaceTerminal(workspace, "Build", fetcher)).resolves.toEqual({ id: "t1", name: "Action: Build" });
|
||||
|
||||
if (capturedRequest === undefined) throw new Error("Expected terminal request");
|
||||
expect(capturedRequest.input).toBe("/api/projects/project%2F1/workspaces/workspace%201/terminals");
|
||||
expect(capturedRequest.init?.method).toBe("POST");
|
||||
expect(capturedRequest.init?.body).toBe(JSON.stringify({ name: "Action: Build", cols: actionTerminalCols, rows: actionTerminalRows }));
|
||||
});
|
||||
|
||||
it("falls back to a generated terminal name when the response omits one", () => {
|
||||
expect(parseTerminalInfo({ id: "t1" }, "Build")).toEqual({ id: "t1", name: "Action: Build" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
|
||||
export const terminalToolId = "core:workspace.terminal";
|
||||
export const actionTerminalCols = 120;
|
||||
export const actionTerminalRows = 32;
|
||||
|
||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ServerTerminalMessage {
|
||||
type: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TerminalCommandDeps {
|
||||
createWebSocket: (url: string) => WebSocket;
|
||||
setTimeout: typeof window.setTimeout;
|
||||
clearTimeout: typeof window.clearTimeout;
|
||||
}
|
||||
|
||||
interface TerminalLocation {
|
||||
protocol: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export async function createWorkspaceTerminal(
|
||||
workspace: Workspace,
|
||||
actionTitle: string,
|
||||
fetcher: FetchLike = window.fetch.bind(window),
|
||||
): Promise<TerminalInfo> {
|
||||
const response = await fetcher(`/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: `Action: ${actionTitle}`, cols: actionTerminalCols, rows: actionTerminalRows }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to create terminal: ${String(response.status)}`);
|
||||
return parseTerminalInfo(await response.json(), actionTitle);
|
||||
}
|
||||
|
||||
export function parseTerminalInfo(value: unknown, actionTitle: string): TerminalInfo {
|
||||
if (!isRecord(value) || typeof value["id"] !== "string") throw new Error("Failed to create terminal: invalid response");
|
||||
const name = value["name"];
|
||||
return { id: value["id"], name: typeof name === "string" && name !== "" ? name : `Action: ${actionTitle}` };
|
||||
}
|
||||
|
||||
export function sendTerminalCommand(workspace: Workspace, terminalId: string, command: string, deps = defaultTerminalCommandDeps()): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = deps.createWebSocket(terminalSocketUrl(workspace, terminalId));
|
||||
const input = normalizeTerminalCommand(command);
|
||||
let settled = false;
|
||||
let sent = false;
|
||||
let fallbackTimer: number | undefined;
|
||||
let completionTimer: number | undefined;
|
||||
const timeout = deps.setTimeout(() => {
|
||||
finish(new Error("Timed out while dispatching command to terminal"));
|
||||
}, 15000);
|
||||
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
deps.clearTimeout(timeout);
|
||||
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
|
||||
if (completionTimer !== undefined) deps.clearTimeout(completionTimer);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Ignore close failures.
|
||||
}
|
||||
if (error === undefined) resolve();
|
||||
else reject(error);
|
||||
};
|
||||
|
||||
const scheduleFinishAfterOutput = () => {
|
||||
completionTimer = deps.setTimeout(() => { finish(); }, 300);
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (settled || sent || socket.readyState !== WebSocket.OPEN) return;
|
||||
sent = true;
|
||||
socket.send(JSON.stringify({ type: "input", data: input }));
|
||||
completionTimer = deps.setTimeout(() => { finish(); }, 5000);
|
||||
};
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
fallbackTimer = deps.setTimeout(send, 3000);
|
||||
});
|
||||
socket.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
void socketDataToText(event.data).then((text) => {
|
||||
const message = parseTerminalSocketMessage(text);
|
||||
if (message?.type === "error") {
|
||||
finish(new Error(message.message ?? "Terminal socket error"));
|
||||
return;
|
||||
}
|
||||
if (sent) {
|
||||
scheduleFinishAfterOutput();
|
||||
return;
|
||||
}
|
||||
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
|
||||
deps.setTimeout(send, 100);
|
||||
}).catch(() => {
|
||||
if (sent) {
|
||||
scheduleFinishAfterOutput();
|
||||
return;
|
||||
}
|
||||
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
|
||||
deps.setTimeout(send, 100);
|
||||
});
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (!sent) finish(new Error("Terminal socket closed before the command was dispatched"));
|
||||
else finish();
|
||||
});
|
||||
socket.addEventListener("error", () => { finish(new Error("Failed to connect to terminal socket")); });
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTerminalCommand(command: string): string {
|
||||
return `${command.replace(/\r?\n$/u, "")}\r`;
|
||||
}
|
||||
|
||||
export async function socketDataToText(data: unknown): Promise<string> {
|
||||
if (typeof data === "string") return data;
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
||||
if (data instanceof Blob) return await data.text();
|
||||
return String(data);
|
||||
}
|
||||
|
||||
export function parseTerminalSocketMessage(text: string): ServerTerminalMessage | undefined {
|
||||
try {
|
||||
const message: unknown = JSON.parse(text);
|
||||
if (!isRecord(message) || typeof message["type"] !== "string") return undefined;
|
||||
const rawMessage = message["message"];
|
||||
return {
|
||||
type: message["type"],
|
||||
...(typeof rawMessage === "string" ? { message: rawMessage } : {}),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function terminalSocketUrl(workspace: Workspace, terminalId: string, location: TerminalLocation = window.location): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const query = `cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`;
|
||||
return `${protocol}//${location.host}/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals/${encodeURIComponent(terminalId)}/socket?${query}`;
|
||||
}
|
||||
|
||||
function defaultTerminalCommandDeps(): TerminalCommandDeps {
|
||||
return {
|
||||
createWebSocket: (url) => new WebSocket(url),
|
||||
setTimeout: window.setTimeout.bind(window),
|
||||
clearTimeout: window.clearTimeout.bind(window),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import { ACTIONS_CONFIG_PATH } from "./config";
|
||||
import { loadWorkspaceActionsConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceActionsClient";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "workspace 1",
|
||||
projectId: "project/1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: false,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
describe("workspace actions client", () => {
|
||||
it("builds the private workspace file URL", () => {
|
||||
expect(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Factions.json");
|
||||
});
|
||||
|
||||
it("loads and parses a valid actions config", async () => {
|
||||
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
|
||||
content: JSON.stringify({ version: 1, actions: [{ id: "build", title: "Build", command: "npm run build" }] }),
|
||||
truncated: false,
|
||||
binary: false,
|
||||
}));
|
||||
|
||||
await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({
|
||||
kind: "loaded",
|
||||
config: {
|
||||
version: 1,
|
||||
actions: [{ id: "build", title: "Build", command: "npm run build", confirm: false }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a visible unavailable state instead of throwing on request failures", async () => {
|
||||
const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 }));
|
||||
|
||||
await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
|
||||
kind: "unavailable",
|
||||
message: `No valid ${ACTIONS_CONFIG_PATH} found.`,
|
||||
hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`,
|
||||
detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns parser details for invalid config files", async () => {
|
||||
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
|
||||
content: JSON.stringify({ version: 2, actions: [] }),
|
||||
truncated: false,
|
||||
binary: false,
|
||||
}));
|
||||
|
||||
await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
|
||||
kind: "unavailable",
|
||||
detail: "Config version must be 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates workspace file responses", () => {
|
||||
expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false });
|
||||
expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import { ACTIONS_CONFIG_PATH, parseActionsConfigText, type WorkspaceActionsConfig } from "./config.js";
|
||||
|
||||
export const actionsConfigUnavailableMessage = `No valid ${ACTIONS_CONFIG_PATH} found.`;
|
||||
export const actionsConfigRefreshHint = `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`;
|
||||
|
||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export type WorkspaceActionsConfigLoadResult =
|
||||
| { kind: "loaded"; config: WorkspaceActionsConfig }
|
||||
| { kind: "unavailable"; message: string; hint: string; detail?: string };
|
||||
|
||||
interface WorkspaceFileResponse {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export async function loadWorkspaceActionsConfig(
|
||||
workspace: Workspace,
|
||||
deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) },
|
||||
): Promise<WorkspaceActionsConfigLoadResult> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await deps.fetch(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH), { cache: "no-store" });
|
||||
} catch (error) {
|
||||
return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`);
|
||||
}
|
||||
|
||||
if (!response.ok) return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: HTTP ${String(response.status)}`);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (error) {
|
||||
return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`);
|
||||
}
|
||||
|
||||
const file = parseWorkspaceFileResponse(body);
|
||||
if (file === undefined) return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}`);
|
||||
if (file.binary) return unavailable(`${ACTIONS_CONFIG_PATH} must be a text file`);
|
||||
if (file.truncated) return unavailable(`${ACTIONS_CONFIG_PATH} is too large and was truncated`);
|
||||
|
||||
const result = parseActionsConfigText(file.content);
|
||||
if (!result.ok) return unavailable(result.error);
|
||||
return { kind: "loaded", config: result.config };
|
||||
}
|
||||
|
||||
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 unavailable(detail: string): WorkspaceActionsConfigLoadResult {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
message: actionsConfigUnavailableMessage,
|
||||
hint: actionsConfigRefreshHint,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
function formatUnknownError(error: unknown): string {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../plugin-api.d.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
+8
-2
@@ -24,13 +24,19 @@
|
||||
],
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@jmfederico/pi-web/plugin-api": ["./plugin-api.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"extensions/**/*.ts",
|
||||
"pi-web-plugins/**/*.ts"
|
||||
"pi-web-plugins/**/*.ts",
|
||||
"plugins/**/*.ts",
|
||||
"plugin-api.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
include: ["src/**/*.test.ts", "plugins/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user