feat(plugins): add actions plugin package

This commit is contained in:
Federico Jaramillo Martinez
2026-05-21 09:31:33 +02:00
parent d693aa06d7
commit fb7903f3cb
20 changed files with 1347 additions and 9 deletions
@@ -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.
+23
View File
@@ -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
View File
@@ -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,
+29
View File
@@ -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
View File
@@ -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",
+146
View File
@@ -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;
}
+82
View File
@@ -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.
+48
View File
@@ -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"
}
}
+289
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function escapeAttr(value: unknown): string {
return escapeHtml(value).replaceAll('"', "&quot;");
}
+99
View File
@@ -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",
});
});
});
+121
View File
@@ -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);
}
+40
View File
@@ -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;
+70
View File
@@ -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" });
});
});
+162
View File
@@ -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);
}
+13
View File
@@ -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
View File
@@ -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
View File
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
include: ["src/**/*.test.ts", "plugins/**/*.test.ts"],
},
});