Archived
feat: run workspace operations in terminals
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import { runWorkspaceActionInTerminal } from "./actionRunner";
|
||||
import type { WorkspaceAction } from "./config";
|
||||
import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "workspace 1",
|
||||
projectId: "project/1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: false,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
const run: InternalTerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: "actions",
|
||||
projectId: workspace.projectId,
|
||||
workspaceId: workspace.id,
|
||||
terminalId: "term1",
|
||||
title: "Build",
|
||||
command: "npm run build",
|
||||
status: "running",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
metadata: { "pi.plugin": "actions", "action.id": "build" },
|
||||
};
|
||||
|
||||
describe("action runner", () => {
|
||||
it("starts workspace actions through the internal terminal command-run helper", async () => {
|
||||
const action: WorkspaceAction = { id: "build", title: "Build", command: "npm run build", confirm: false };
|
||||
const runCommand = vi.fn<InternalTerminalCommandRunsRuntime["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
|
||||
const terminal: InternalTerminalCommandRunsRuntime = {
|
||||
runCommand,
|
||||
open: vi.fn(),
|
||||
};
|
||||
|
||||
const handle = await runWorkspaceActionInTerminal(terminal, workspace, action);
|
||||
|
||||
expect(handle.run).toEqual(run);
|
||||
await expect(handle.completed).resolves.toEqual(run);
|
||||
expect(runCommand).toHaveBeenCalledWith({
|
||||
workspace,
|
||||
title: "Build",
|
||||
command: "npm run build",
|
||||
open: true,
|
||||
metadata: { "pi.plugin": "actions", "action.id": "build" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import type { WorkspaceAction } from "./config.js";
|
||||
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
|
||||
|
||||
export function runWorkspaceActionInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, action: WorkspaceAction): ReturnType<InternalTerminalCommandRunsRuntime["runCommand"]> {
|
||||
return terminal.runCommand({
|
||||
workspace,
|
||||
title: action.title,
|
||||
command: action.command,
|
||||
open: true,
|
||||
metadata: {
|
||||
"pi.plugin": "actions",
|
||||
"action.id": action.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { runWorkspaceActionInTerminal } from "./actionRunner.js";
|
||||
import { requestPiWebRender } from "./piWebPrivateUi.js";
|
||||
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
|
||||
import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js";
|
||||
|
||||
export const actionsPanelTagName = "pi-web-actions-panel";
|
||||
@@ -30,6 +31,7 @@ export function actionsPanelBadge(workspace: Workspace): string | number | undef
|
||||
class PiWebActionsPanel extends HTMLElement {
|
||||
private workspaceValue: Workspace | undefined;
|
||||
private openTerminalValue: OpenTerminal | undefined;
|
||||
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
|
||||
private runningActionId: string | undefined;
|
||||
private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined;
|
||||
private readonly root: ShadowRoot;
|
||||
@@ -51,6 +53,10 @@ class PiWebActionsPanel extends HTMLElement {
|
||||
this.openTerminalValue = value;
|
||||
}
|
||||
|
||||
set terminalCommandRuns(value: InternalTerminalCommandRunsRuntime | undefined) {
|
||||
this.terminalCommandRunsValue = value;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
window.addEventListener(configChangedEvent, this.onConfigChanged);
|
||||
this.render();
|
||||
@@ -104,7 +110,7 @@ class PiWebActionsPanel extends HTMLElement {
|
||||
if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`;
|
||||
if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>${this.renderStatus()}`;
|
||||
return `
|
||||
<p class="muted">Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
|
||||
<p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
|
||||
${renderActionGroups(state.config.actions, this.runningActionId)}
|
||||
${this.renderStatus()}
|
||||
`;
|
||||
@@ -132,24 +138,26 @@ class PiWebActionsPanel extends HTMLElement {
|
||||
if (this.runningActionId !== undefined) return;
|
||||
if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return;
|
||||
|
||||
const terminal = this.terminalCommandRunsValue;
|
||||
if (terminal === undefined) {
|
||||
this.status = { kind: "error", message: "This PI WEB version does not provide terminal command helpers to plugins." };
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
this.runningActionId = action.id;
|
||||
this.status = { kind: "info", message: `Creating terminal for ${action.title}…` };
|
||||
this.status = { kind: "info", message: `Starting ${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);
|
||||
const handle = await runWorkspaceActionInTerminal(terminal, workspace, action);
|
||||
this.status = {
|
||||
kind: "success",
|
||||
message: `Dispatched to terminal “${terminal.name}”.`,
|
||||
message: `Started terminal command “${handle.run.title}”.`,
|
||||
detail: action.command,
|
||||
};
|
||||
this.runningActionId = undefined;
|
||||
this.render();
|
||||
this.openWorkspaceTerminal(terminal.id);
|
||||
} catch (error) {
|
||||
this.runningActionId = undefined;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
@@ -158,6 +166,10 @@ class PiWebActionsPanel extends HTMLElement {
|
||||
}
|
||||
|
||||
private openWorkspaceTerminal(terminalId?: string): void {
|
||||
if (this.terminalCommandRunsValue !== undefined) {
|
||||
this.terminalCommandRunsValue.open(terminalId === undefined ? undefined : { terminalId });
|
||||
return;
|
||||
}
|
||||
if (this.openTerminalValue === undefined) {
|
||||
this.status = { kind: "error", message: "This PI WEB version does not provide terminal navigation to plugins." };
|
||||
this.render();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
|
||||
import { ACTIONS_CONFIG_PATH } from "./config.js";
|
||||
import { actionsPanelBadge, defineActionsPanelElement } from "./actionsPanelElement.js";
|
||||
import { terminalCommandRunsFromContext } from "./piWebInternal.js";
|
||||
|
||||
const plugin: PiWebPlugin = {
|
||||
apiVersion: 1,
|
||||
@@ -29,7 +30,7 @@ const plugin: PiWebPlugin = {
|
||||
title: "Actions",
|
||||
order: 40,
|
||||
badge: ({ workspace }) => actionsPanelBadge(workspace),
|
||||
render: ({ workspace, openTerminal }) => html`<pi-web-actions-panel .workspace=${workspace} .openTerminal=${openTerminal}></pi-web-actions-panel>`,
|
||||
render: (context) => html`<pi-web-actions-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-actions-panel>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
|
||||
export interface InternalRunTerminalCommandInput {
|
||||
workspace: Workspace;
|
||||
title: string;
|
||||
command: string;
|
||||
metadata?: Record<string, string>;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export interface InternalTerminalCommandRun {
|
||||
id: string;
|
||||
origin: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
terminalId: string;
|
||||
title: string;
|
||||
command: string;
|
||||
status: "queued" | "running" | "succeeded" | "failed";
|
||||
exitCode?: number;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface InternalTerminalCommandRunHandle {
|
||||
run: InternalTerminalCommandRun;
|
||||
completed: Promise<InternalTerminalCommandRun>;
|
||||
}
|
||||
|
||||
export interface InternalTerminalCommandRunsRuntime {
|
||||
runCommand(input: InternalRunTerminalCommandInput): Promise<InternalTerminalCommandRunHandle>;
|
||||
open(options?: { terminalId?: string | undefined }): void;
|
||||
}
|
||||
|
||||
export function terminalCommandRunsFromContext(context: unknown): InternalTerminalCommandRunsRuntime | undefined {
|
||||
if (!isRecord(context)) return undefined;
|
||||
const internal = context["piWebInternal"];
|
||||
if (!isRecord(internal)) return undefined;
|
||||
const terminalCommandRuns = internal["terminalCommandRuns"];
|
||||
if (!isRecord(terminalCommandRuns)) return undefined;
|
||||
const runCommand = terminalCommandRuns["runCommand"];
|
||||
const open = terminalCommandRuns["open"];
|
||||
if (!isRunCommand(runCommand) || !isOpen(open)) return undefined;
|
||||
return {
|
||||
runCommand: (input) => runCommand(input),
|
||||
open: (options) => { open(options); },
|
||||
};
|
||||
}
|
||||
|
||||
function isRunCommand(value: unknown): value is InternalTerminalCommandRunsRuntime["runCommand"] {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isOpen(value: unknown): value is InternalTerminalCommandRunsRuntime["open"] {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user