Archived
feat: run workspace operations in terminals
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
"@jmfederico/pi-web-actions": patch
|
||||
---
|
||||
|
||||
Run workspace deletion and configurable workspace actions in visible PI WEB terminals with reload-safe command-run tracking, mobile-friendly cancellation, and shell continuation after command completion.
|
||||
+3
-2
@@ -348,7 +348,8 @@ Notes:
|
||||
- Other `state` fields may exist at runtime, but they are PI WEB internals and can change quickly.
|
||||
- `enabled` is evaluated when the action palette asks for actions.
|
||||
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
||||
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal after creating one through the terminal API.
|
||||
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
|
||||
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. PI WEB may attach `piWebInternal` fields at runtime for first-party dogfooding; plugins should not depend on those fields because they can change or disappear without notice.
|
||||
|
||||
#### Keyboard shortcuts
|
||||
|
||||
@@ -399,7 +400,7 @@ interface WorkspacePanelContext {
|
||||
}
|
||||
```
|
||||
|
||||
`workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. Use `openTerminal({ terminalId })` when a panel creates a terminal and wants PI WEB to navigate to that specific terminal. If a panel needs file, git, or session data, prefer explicit `fetch()` calls and keep them isolated.
|
||||
`workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. If a panel needs file, git, terminal, or session data beyond the helpers documented here, prefer explicit `fetch()` calls and keep them isolated.
|
||||
|
||||
Useful workspace shape:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 that terminal so the user can monitor progress or take over.
|
||||
The plugin adds an **Actions** workspace tab. Actions run the configured shell command in a dedicated PI WEB terminal and switch to that terminal so the user can monitor progress.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -68,7 +68,7 @@ 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.
|
||||
This first-party plugin dogfoods PI WEB's internal terminal command-run helper for command execution while that API incubates. It also reads `.pi-web/actions.json` through PI WEB's private workspace file endpoint. These internals are not stable public plugin APIs yet, so compatibility is best-effort and may require updates alongside PI WEB releases.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileSuggestion } from "../../../shared/apiTypes";
|
||||
import type { FileSuggestion, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
parseSessionStatus,
|
||||
parseSlashCommand,
|
||||
parseStopped,
|
||||
parseTerminalCommandRun,
|
||||
parseTerminalInfo,
|
||||
parseThinkingLevelsResponse,
|
||||
parseWorkspace,
|
||||
@@ -93,8 +94,45 @@ export const terminalsApi = {
|
||||
terminals: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
|
||||
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
||||
closeTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
||||
continueTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
||||
runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => request(`/api/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
|
||||
listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
||||
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
|
||||
cancelCommandRun: (runId: string) => request(`/api/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }),
|
||||
};
|
||||
|
||||
async function getOptionalTerminalCommandRun(runId: string): Promise<TerminalCommandRun | undefined> {
|
||||
const response = await fetch(`/api/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
throw new Error(apiErrorMessage(body) ?? response.statusText);
|
||||
}
|
||||
return parseTerminalCommandRun(await response.json());
|
||||
}
|
||||
|
||||
function terminalCommandRunFilterQuery(filter: TerminalCommandRunFilter | undefined): string {
|
||||
if (filter === undefined) return "";
|
||||
const params = new URLSearchParams();
|
||||
if (filter.projectId !== undefined) params.set("projectId", filter.projectId);
|
||||
if (filter.workspaceId !== undefined) params.set("workspaceId", filter.workspaceId);
|
||||
if (filter.terminalId !== undefined) params.set("terminalId", filter.terminalId);
|
||||
if (filter.statuses !== undefined && filter.statuses.length > 0) params.set("statuses", filter.statuses.join(","));
|
||||
if (filter.metadata !== undefined && Object.keys(filter.metadata).length > 0) params.set("metadata", JSON.stringify(filter.metadata));
|
||||
const query = params.toString();
|
||||
return query === "" ? "" : `?${query}`;
|
||||
}
|
||||
|
||||
function apiErrorMessage(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const error = value["error"];
|
||||
return typeof error === "string" ? error : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("accepts legacy array message pages and paged message responses", () => {
|
||||
@@ -70,6 +70,61 @@ describe("API parsers", () => {
|
||||
expect(() => parseFileContentResponse({ ...textFile, mediaType: "video" })).toThrow("Invalid file media type");
|
||||
});
|
||||
|
||||
it("parses terminal info with optional command-run ownership", () => {
|
||||
expect(parseTerminalInfo({
|
||||
id: "t1",
|
||||
cwd: "/repo",
|
||||
name: "Build",
|
||||
createdAt: "now",
|
||||
exited: false,
|
||||
commandRunId: "run1",
|
||||
})).toMatchObject({ id: "t1", commandRunId: "run1" });
|
||||
});
|
||||
|
||||
it("parses terminal command runs", () => {
|
||||
expect(parseTerminalCommandRun({
|
||||
id: "run1",
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
terminalId: "t1",
|
||||
title: "Build",
|
||||
command: "npm run build",
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
createdAt: "now",
|
||||
startedAt: "then",
|
||||
completedAt: "later",
|
||||
metadata: { "pi.operation": "test" },
|
||||
})).toEqual({
|
||||
id: "run1",
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
terminalId: "t1",
|
||||
title: "Build",
|
||||
command: "npm run build",
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
createdAt: "now",
|
||||
startedAt: "then",
|
||||
completedAt: "later",
|
||||
metadata: { "pi.operation": "test" },
|
||||
});
|
||||
expect(() => parseTerminalCommandRun({
|
||||
id: "run1",
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
terminalId: "t1",
|
||||
title: "Build",
|
||||
command: "npm run build",
|
||||
status: "done",
|
||||
createdAt: "now",
|
||||
metadata: {},
|
||||
})).toThrow("Invalid terminal command run status");
|
||||
});
|
||||
|
||||
it("parses command result variants", () => {
|
||||
expect(parseCommandResult({ type: "unsupported", message: "nope" })).toEqual({ type: "unsupported", message: "nope" });
|
||||
expect(parseCommandResult({ type: "select", requestId: "r1", title: "Pick", options: [{ value: "v", label: "Label", description: "desc" }] })).toEqual({ type: "select", requestId: "r1", title: "Pick", options: [{ value: "v", label: "Label", description: "desc" }] });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -306,7 +306,39 @@ export function parseGitDiffResponse(value: unknown): GitDiffResponse {
|
||||
|
||||
export function parseTerminalInfo(value: unknown): TerminalInfo {
|
||||
const record = requireRecord(value);
|
||||
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) };
|
||||
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")), ...optionalField("commandRunId", optionalString(record, "commandRunId")) };
|
||||
}
|
||||
|
||||
export function parseTerminalCommandRun(value: unknown): TerminalCommandRun {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
origin: requireString(record, "origin"),
|
||||
projectId: requireString(record, "projectId"),
|
||||
workspaceId: requireString(record, "workspaceId"),
|
||||
terminalId: requireString(record, "terminalId"),
|
||||
title: requireString(record, "title"),
|
||||
command: requireString(record, "command"),
|
||||
status: parseTerminalCommandRunStatus(record["status"]),
|
||||
...optionalField("exitCode", optionalNumber(record, "exitCode")),
|
||||
createdAt: requireString(record, "createdAt"),
|
||||
...optionalField("startedAt", optionalString(record, "startedAt")),
|
||||
...optionalField("completedAt", optionalString(record, "completedAt")),
|
||||
metadata: parseStringRecord(record["metadata"], "metadata"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTerminalCommandRunStatus(value: unknown): TerminalCommandRunStatus {
|
||||
if (value !== "queued" && value !== "running" && value !== "succeeded" && value !== "failed") throw new Error("Invalid terminal command run status");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseStringRecord(value: unknown, key: string): Record<string, string> {
|
||||
const record = requireRecord(value);
|
||||
return Object.fromEntries(Object.entries(record).map(([field, fieldValue]) => {
|
||||
if (typeof fieldValue !== "string") throw new Error(`Expected string record field: ${key}.${field}`);
|
||||
return [field, fieldValue];
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseWorkspaceActivity(value: unknown): WorkspaceActivity {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/types";
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AppState {
|
||||
sessionActivities: Record<string, SessionActivity>;
|
||||
workspaceActivities: Record<string, WorkspaceActivity>;
|
||||
workspacesByProjectId: Record<string, Workspace[]>;
|
||||
workspaceDeletionRuns: Record<string, TerminalCommandRun>;
|
||||
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
||||
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
@@ -109,6 +110,7 @@ export function initialAppState(): AppState {
|
||||
sessionActivities: {},
|
||||
workspaceActivities: {},
|
||||
workspacesByProjectId: {},
|
||||
workspaceDeletionRuns: {},
|
||||
commandDialog: undefined,
|
||||
modelDialog: undefined,
|
||||
thinkingDialog: undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
||||
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -10,19 +10,21 @@ import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
import { GitController } from "../controllers/gitController";
|
||||
import { ProjectController } from "../controllers/projectController";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
import { WorkspaceController } from "../controllers/workspaceController";
|
||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import { RealtimeSocket } from "../sessionSocket";
|
||||
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, WorkspacePanelContext } from "../plugins/types";
|
||||
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
|
||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||
import { corePlugin } from "../plugins/core";
|
||||
import { themePackPlugin } from "../plugins/themes";
|
||||
import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry } from "../plugins/registry";
|
||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||
import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode";
|
||||
import { readRoute, writeRoute, type AppRoute } from "../route";
|
||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||
import "./ProjectList";
|
||||
import "./WorkspaceList";
|
||||
import "./SessionList";
|
||||
@@ -107,6 +109,10 @@ export class PiWebApp extends LitElement {
|
||||
private mobileTabsResizeObserver: ResizeObserver | undefined;
|
||||
private terminalAutoStartWorkspaceId: string | undefined;
|
||||
private piWebStatusTimer: number | undefined;
|
||||
private workspaceDeletionPollTimer: number | undefined;
|
||||
private refreshingWorkspaceDeletionRuns = false;
|
||||
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
|
||||
private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>();
|
||||
private routeRestoreInProgress = false;
|
||||
private restoringRouteTerminalId: string | undefined;
|
||||
private readonly plugins = createPluginRegistry();
|
||||
@@ -129,12 +135,14 @@ export class PiWebApp extends LitElement {
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
void this.refreshWorkspaceActivity();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
};
|
||||
private readonly onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
void this.refreshWorkspaceActivity();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
};
|
||||
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
|
||||
@@ -209,6 +217,8 @@ export class PiWebApp extends LitElement {
|
||||
this.git.dispose();
|
||||
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
|
||||
this.piWebStatusTimer = undefined;
|
||||
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
|
||||
this.workspaceDeletionPollTimer = undefined;
|
||||
this.contextItemsResizeObserver?.disconnect();
|
||||
this.contextItemsResizeObserver = undefined;
|
||||
this.observedContextItems = undefined;
|
||||
@@ -244,6 +254,7 @@ export class PiWebApp extends LitElement {
|
||||
private async loadProjectsAndRestoreRoute() {
|
||||
await this.projects.loadProjects();
|
||||
await this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
|
||||
private async refreshPiWebStatus(): Promise<void> {
|
||||
@@ -272,6 +283,7 @@ export class PiWebApp extends LitElement {
|
||||
this.sessions.refreshSelectedSession(),
|
||||
this.refreshPiWebStatus(),
|
||||
this.refreshWorkspaceActivity(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
this.refreshCurrentWorkspaceSurface(),
|
||||
]);
|
||||
} finally {
|
||||
@@ -377,6 +389,21 @@ export class PiWebApp extends LitElement {
|
||||
this.openWorkspaceTool("core:workspace.terminal");
|
||||
}
|
||||
|
||||
private terminalCommandRunsForOrigin(origin: string): TerminalCommandRunsInternalRuntime {
|
||||
const existing = this.terminalCommandRunRuntimes.get(origin);
|
||||
if (existing !== undefined) return existing;
|
||||
const runtime = createTerminalCommandRunsRuntime(origin, {
|
||||
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(workspace, options); },
|
||||
});
|
||||
this.terminalCommandRunRuntimes.set(origin, runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private async openRuntimeTerminal(workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
|
||||
if (workspace !== undefined && this.state.selectedWorkspace?.id !== workspace.id) await this.workspaces.selectWorkspace(workspace);
|
||||
this.openTerminal(options);
|
||||
}
|
||||
|
||||
private selectTerminal(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void {
|
||||
this.rememberSelectedTerminal(terminalId);
|
||||
this.setState({ selectedTerminalId: terminalId });
|
||||
@@ -413,6 +440,7 @@ export class PiWebApp extends LitElement {
|
||||
if (!this.routeRestoreInProgress) this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true });
|
||||
if (next.selectedWorkspace === undefined) return;
|
||||
void this.refreshActiveTerminals(next.selectedWorkspace);
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
this.refreshSelectedWorkspaceTool(next.workspaceTool);
|
||||
this.git.updatePolling();
|
||||
}
|
||||
@@ -430,8 +458,10 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private handleRealtimeEvent(event: RealtimeEvent): void {
|
||||
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
|
||||
else if (isTerminalEvent(event)) this.applyTerminalEvent(event);
|
||||
else this.sessions.applyGlobalEvent(event);
|
||||
else if (isTerminalEvent(event)) {
|
||||
this.applyTerminalEvent(event);
|
||||
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
|
||||
} else this.sessions.applyGlobalEvent(event);
|
||||
}
|
||||
|
||||
private applyTerminalEvent(event: TerminalUiEvent): void {
|
||||
@@ -516,6 +546,7 @@ export class PiWebApp extends LitElement {
|
||||
.workspaces=${this.state.workspaces}
|
||||
.selected=${this.state.selectedWorkspace}
|
||||
.activities=${this.state.workspaceActivities}
|
||||
.deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("workspaces")}
|
||||
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
|
||||
@@ -524,6 +555,7 @@ export class PiWebApp extends LitElement {
|
||||
this.expandNavigationSection("sessions");
|
||||
await this.workspaces.selectWorkspace(workspace);
|
||||
})}
|
||||
.onDelete=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||
></workspace-list>
|
||||
<session-list
|
||||
.sessions=${this.state.sessions}
|
||||
@@ -636,9 +668,10 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
|
||||
return {
|
||||
const createContext = (origin: string): WorkspacePanelContext => installWorkspacePanelScope({
|
||||
workspace,
|
||||
state: this.state,
|
||||
piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) },
|
||||
fileTree: this.state.fileTree,
|
||||
expandedDirs: this.state.expandedDirs,
|
||||
selectedFilePath: this.state.selectedFilePath,
|
||||
@@ -659,7 +692,8 @@ export class PiWebApp extends LitElement {
|
||||
onRefreshGit: () => { void this.git.refreshGit(); },
|
||||
onSelectDiff: (path: string) => { void this.git.selectDiff(path); },
|
||||
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); },
|
||||
};
|
||||
}, createContext);
|
||||
return createContext("core");
|
||||
}
|
||||
|
||||
private getActions(): AppAction[] {
|
||||
@@ -684,8 +718,9 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private createPluginRuntimeContext(): PluginRuntimeContext {
|
||||
return {
|
||||
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
|
||||
state: this.state,
|
||||
piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) },
|
||||
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
||||
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
||||
addProject: () => { this.setState({ projectDialogOpen: true }); },
|
||||
@@ -699,10 +734,114 @@ export class PiWebApp extends LitElement {
|
||||
refreshGit: () => this.git.refreshGit(),
|
||||
refreshAppData: () => this.refreshAppData(),
|
||||
reloadPage: () => { this.hardReloadApp(); },
|
||||
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
|
||||
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
|
||||
archiveSession: () => this.sessions.archiveSession(),
|
||||
stopActiveWork: () => this.sessions.stopActiveWork(),
|
||||
};
|
||||
}, createContext);
|
||||
return createContext("core");
|
||||
}
|
||||
|
||||
private async deleteWorkspace(workspace = this.state.selectedWorkspace): Promise<void> {
|
||||
if (workspace === undefined) return;
|
||||
if (!canDeleteWorkspace(workspace)) {
|
||||
this.setState({ error: "Only secondary Git worktrees can be deleted" });
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceDeletionPending(this.state, workspace)) return;
|
||||
const label = workspace.branch ?? workspace.label;
|
||||
const confirmed = confirm(`Delete workspace ${label}?\n\nThis will run git worktree remove and delete:\n${workspace.path}\n\nThe Git branch will not be deleted.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
|
||||
if (mainWorkspace === undefined) {
|
||||
this.setState({ error: "Project main workspace not found" });
|
||||
return;
|
||||
}
|
||||
const handle = await this.terminalCommandRunsForOrigin("core").runCommand({
|
||||
workspace: mainWorkspace,
|
||||
title: `Delete workspace: ${label}`,
|
||||
command: `git worktree remove ${shellQuote(workspace.path)}`,
|
||||
open: true,
|
||||
metadata: workspaceDeletionMetadata(workspace),
|
||||
});
|
||||
this.recordWorkspaceDeletionRun(handle.run);
|
||||
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run)).catch((error: unknown) => {
|
||||
this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
|
||||
});
|
||||
} catch (error) {
|
||||
this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
|
||||
}
|
||||
}
|
||||
|
||||
private async mainWorkspaceForProject(projectId: string): Promise<Workspace | undefined> {
|
||||
let workspaces = this.state.selectedProject?.id === projectId ? this.state.workspaces : this.state.workspacesByProjectId[projectId];
|
||||
if (workspaces === undefined || workspaces.length === 0) workspaces = await this.workspaces.refreshProjectWorkspaces(projectId);
|
||||
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
||||
}
|
||||
|
||||
private recordWorkspaceDeletionRun(run: TerminalCommandRun): void {
|
||||
const workspaceId = targetWorkspaceIdForRun(run);
|
||||
if (workspaceId === undefined) return;
|
||||
this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } });
|
||||
this.updateWorkspaceDeletionPolling();
|
||||
}
|
||||
|
||||
private async refreshWorkspaceDeletionRuns(): Promise<void> {
|
||||
if (this.refreshingWorkspaceDeletionRuns) return;
|
||||
const project = this.state.selectedProject;
|
||||
if (project === undefined) {
|
||||
this.setState({ workspaceDeletionRuns: {} });
|
||||
this.updateWorkspaceDeletionPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshingWorkspaceDeletionRuns = true;
|
||||
try {
|
||||
const runs = await this.terminalCommandRunsForOrigin("core").listCommandRuns(workspaceDeletionRunFilter(project.id));
|
||||
const latestRuns = latestWorkspaceDeletionRuns(runs);
|
||||
this.setState({ workspaceDeletionRuns: latestRuns });
|
||||
for (const run of Object.values(latestRuns)) {
|
||||
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh workspace deletion runs", error);
|
||||
} finally {
|
||||
this.refreshingWorkspaceDeletionRuns = false;
|
||||
this.updateWorkspaceDeletionPolling();
|
||||
}
|
||||
}
|
||||
|
||||
private updateWorkspaceDeletionPolling(): void {
|
||||
const hasPendingDeletion = Object.values(this.state.workspaceDeletionRuns).some(isWorkspaceDeletionRunPending);
|
||||
if (hasPendingDeletion && this.workspaceDeletionPollTimer === undefined) {
|
||||
this.workspaceDeletionPollTimer = window.setInterval(() => { void this.refreshWorkspaceDeletionRuns(); }, 1000);
|
||||
return;
|
||||
}
|
||||
if (!hasPendingDeletion && this.workspaceDeletionPollTimer !== undefined) {
|
||||
window.clearInterval(this.workspaceDeletionPollTimer);
|
||||
this.workspaceDeletionPollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun): Promise<void> {
|
||||
if (this.handledWorkspaceDeletionRunIds.has(run.id)) return;
|
||||
const workspaceId = targetWorkspaceIdForRun(run);
|
||||
if (workspaceId === undefined) return;
|
||||
this.handledWorkspaceDeletionRunIds.add(run.id);
|
||||
|
||||
if (run.status === "succeeded") {
|
||||
await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId);
|
||||
this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) });
|
||||
this.updateWorkspaceDeletionPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status === "failed") {
|
||||
this.setState({ error: "Workspace deletion failed. See terminal output." });
|
||||
this.updateWorkspaceDeletionPolling();
|
||||
}
|
||||
}
|
||||
|
||||
private runAction(action: AppAction): void {
|
||||
@@ -1135,6 +1274,18 @@ function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
|
||||
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function omitWorkspaceDeletionRun(runs: Record<string, TerminalCommandRun>, workspaceId: string): Record<string, TerminalCommandRun> {
|
||||
return Object.fromEntries(Object.entries(runs).filter(([candidate]) => candidate !== workspaceId));
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
|
||||
import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { terminalSocket, terminalsApi, type TerminalInfo, type Workspace } from "../api";
|
||||
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
|
||||
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
|
||||
|
||||
const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
|
||||
@@ -14,6 +14,7 @@ const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
|
||||
};
|
||||
|
||||
const DEFAULT_TERMINAL_SIZE: TerminalSize = { cols: 100, rows: 30 };
|
||||
const COMMAND_RUN_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
@customElement("terminal-panel")
|
||||
export class TerminalPanel extends LitElement {
|
||||
@@ -23,10 +24,13 @@ export class TerminalPanel extends LitElement {
|
||||
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
||||
@query(".terminal-host") private terminalHost?: HTMLDivElement | null;
|
||||
@state() private terminals: TerminalInfo[] = [];
|
||||
@state() private commandRuns: TerminalCommandRun[] = [];
|
||||
@state() private selectedId: string | undefined;
|
||||
@state() private loading = false;
|
||||
@state() private error: string | undefined;
|
||||
@state() private visible = false;
|
||||
@state() private cancellingRunIds: string[] = [];
|
||||
@state() private continuingTerminalIds: string[] = [];
|
||||
|
||||
private terminal: Terminal | undefined;
|
||||
private fitAddon: FitAddon | undefined;
|
||||
@@ -38,6 +42,7 @@ export class TerminalPanel extends LitElement {
|
||||
private observedCwd: string | undefined;
|
||||
private loadedCwd: string | undefined;
|
||||
private autoStartConsumedCwd: string | undefined;
|
||||
private commandRunPollTimer: number | undefined;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -57,6 +62,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.intersectionObserver = undefined;
|
||||
this.themeObserver?.disconnect();
|
||||
this.themeObserver = undefined;
|
||||
this.updateCommandRunPolling(false);
|
||||
this.disposeTerminalView();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -68,7 +74,11 @@ export class TerminalPanel extends LitElement {
|
||||
this.loadedCwd = undefined;
|
||||
this.autoStartConsumedCwd = undefined;
|
||||
this.terminals = [];
|
||||
this.commandRuns = [];
|
||||
this.selectedId = undefined;
|
||||
this.cancellingRunIds = [];
|
||||
this.continuingTerminalIds = [];
|
||||
this.updateCommandRunPolling(false);
|
||||
this.disposeTerminalView();
|
||||
return;
|
||||
}
|
||||
@@ -84,6 +94,8 @@ export class TerminalPanel extends LitElement {
|
||||
}
|
||||
|
||||
override updated(changed: PropertyValues<this>): void {
|
||||
if (!this.visible) this.updateCommandRunPolling(false);
|
||||
else if (this.hasPendingCommandRuns()) this.updateCommandRunPolling(true);
|
||||
this.loadVisibleWorkspaceTerminals();
|
||||
if (changed.has("selectedTerminalId") && this.shouldReloadForRequestedTerminal()) void this.loadTerminals();
|
||||
this.ensureTerminalView();
|
||||
@@ -100,11 +112,17 @@ export class TerminalPanel extends LitElement {
|
||||
this.loading = true;
|
||||
this.error = undefined;
|
||||
try {
|
||||
if (this.workspace === undefined) return;
|
||||
const workspace = this.workspace;
|
||||
if (workspace === undefined) return;
|
||||
const shouldAutoStart = this.consumeAutoStart();
|
||||
const terminals = await terminalsApi.terminals(this.workspace.projectId, this.workspace.id);
|
||||
const [terminals, commandRuns] = await Promise.all([
|
||||
terminalsApi.terminals(workspace.projectId, workspace.id),
|
||||
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
|
||||
]);
|
||||
this.terminals = terminals;
|
||||
this.commandRuns = commandRuns;
|
||||
this.selectPreferredLoadedTerminal({ replaceUrl: true });
|
||||
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
|
||||
if (terminals.length === 0 && shouldAutoStart) await this.startTerminal();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
@@ -185,6 +203,75 @@ export class TerminalPanel extends LitElement {
|
||||
this.onSelectTerminal(id);
|
||||
}
|
||||
|
||||
private selectedTerminalInfo(): TerminalInfo | undefined {
|
||||
return this.terminals.find((terminal) => terminal.id === this.selectedId);
|
||||
}
|
||||
|
||||
private selectedCommandRun(): TerminalCommandRun | undefined {
|
||||
const commandRunId = this.selectedTerminalInfo()?.commandRunId;
|
||||
if (commandRunId === undefined) return undefined;
|
||||
return this.commandRuns.find((run) => run.id === commandRunId);
|
||||
}
|
||||
|
||||
private async loadCommandRuns(): Promise<void> {
|
||||
const workspace = this.workspace;
|
||||
if (workspace === undefined) return;
|
||||
try {
|
||||
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id });
|
||||
this.commandRuns = commandRuns;
|
||||
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run)));
|
||||
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateCommandRunPolling(shouldPoll: boolean): void {
|
||||
if (shouldPoll && this.commandRunPollTimer === undefined) {
|
||||
this.commandRunPollTimer = window.setInterval(() => { void this.loadCommandRuns(); }, COMMAND_RUN_POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
if (!shouldPoll && this.commandRunPollTimer !== undefined) {
|
||||
window.clearInterval(this.commandRunPollTimer);
|
||||
this.commandRunPollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private hasPendingCommandRuns(commandRuns = this.commandRuns): boolean {
|
||||
return commandRuns.some(isCommandRunPending);
|
||||
}
|
||||
|
||||
private async cancelCommandRun(run: TerminalCommandRun): Promise<void> {
|
||||
if (!isCommandRunPending(run) || this.cancellingRunIds.includes(run.id)) return;
|
||||
this.error = undefined;
|
||||
this.cancellingRunIds = [...this.cancellingRunIds, run.id];
|
||||
try {
|
||||
await terminalsApi.cancelCommandRun(run.id);
|
||||
await this.loadCommandRuns();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => runId !== run.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async continueTerminal(id: string): Promise<void> {
|
||||
if (this.workspace === undefined || this.continuingTerminalIds.includes(id)) return;
|
||||
this.error = undefined;
|
||||
this.continuingTerminalIds = [...this.continuingTerminalIds, id];
|
||||
try {
|
||||
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id);
|
||||
this.terminals = this.terminals.map((item) => item.id === id ? terminal : item);
|
||||
if (this.socket === undefined) this.disposeTerminalView();
|
||||
this.fitAndNotify();
|
||||
this.terminal?.focus();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.continuingTerminalIds = this.continuingTerminalIds.filter((terminalId) => terminalId !== id);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureTerminalView(): void {
|
||||
const workspace = this.workspace;
|
||||
const terminalHost = this.terminalHostElement();
|
||||
@@ -230,6 +317,7 @@ export class TerminalPanel extends LitElement {
|
||||
if (message.type === "exit") {
|
||||
terminal.writeln(`\r\n[process exited${message.exitCode === undefined ? "" : ` with code ${String(message.exitCode)}`}]`);
|
||||
this.terminals = this.terminals.map((item) => item.id === terminalId ? { ...item, exited: true, ...(message.exitCode === undefined ? {} : { exitCode: message.exitCode }) } : item);
|
||||
void this.loadCommandRuns();
|
||||
}
|
||||
if (message.type === "error") terminal.writeln(`\r\n[terminal error: ${message.message}]`);
|
||||
} catch (error) {
|
||||
@@ -301,6 +389,39 @@ export class TerminalPanel extends LitElement {
|
||||
this.fitAddon = undefined;
|
||||
}
|
||||
|
||||
private renderCommandRunNotice() {
|
||||
const run = this.selectedCommandRun();
|
||||
if (run === undefined) return null;
|
||||
const terminal = this.selectedTerminalInfo();
|
||||
if (isCommandRunPending(run)) {
|
||||
const cancelling = this.cancellingRunIds.includes(run.id);
|
||||
return html`
|
||||
<section class="command-run-notice running">
|
||||
<div>
|
||||
<strong>${run.title}</strong>
|
||||
<p>Command is running. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> or use the button to cancel.</p>
|
||||
<code>${run.command}</code>
|
||||
</div>
|
||||
<button class="danger" ?disabled=${cancelling} @click=${() => { void this.cancelCommandRun(run); }}>${cancelling ? "Cancel sent…" : "Cancel command"}</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (terminal?.exited === true) {
|
||||
const continuing = this.continuingTerminalIds.includes(terminal.id);
|
||||
return html`
|
||||
<section class=${`command-run-notice ${run.status}`}>
|
||||
<div>
|
||||
<strong>${commandRunCompletionLabel(run)}</strong>
|
||||
<p>Output is preserved. Continue in a shell to inspect or run follow-up commands.</p>
|
||||
<code>${run.command}</code>
|
||||
</div>
|
||||
<button ?disabled=${continuing} @click=${() => { void this.continueTerminal(terminal.id); }}>${continuing ? "Starting shell…" : "Continue in shell"}</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section class="terminal-shell">
|
||||
@@ -314,6 +435,7 @@ export class TerminalPanel extends LitElement {
|
||||
<button class="new" ?disabled=${this.workspace === undefined} @click=${() => { void this.startTerminal(); }}>+ Shell</button>
|
||||
</div>
|
||||
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
|
||||
${this.renderCommandRunNotice()}
|
||||
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
|
||||
<div class="terminal-host"></div>
|
||||
</section>
|
||||
@@ -330,7 +452,16 @@ export class TerminalPanel extends LitElement {
|
||||
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
|
||||
button small:hover { color: var(--pi-danger); }
|
||||
button.danger { color: var(--pi-danger); }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.command-run-notice { flex: 0 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 8px 10px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); color: var(--pi-text); }
|
||||
.command-run-notice.running { border-color: var(--pi-warning-border); }
|
||||
.command-run-notice.succeeded { border-color: var(--pi-success-border); }
|
||||
.command-run-notice.failed { border-color: var(--pi-danger); }
|
||||
.command-run-notice p { margin: 3px 0; color: var(--pi-muted); }
|
||||
.command-run-notice code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice kbd { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 0 4px; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice button { justify-self: end; max-width: none; }
|
||||
.terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
|
||||
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
|
||||
@@ -361,6 +492,15 @@ type ServerTerminalMessage =
|
||||
| { type: "exit"; exitCode?: number }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
function isCommandRunPending(run: TerminalCommandRun): boolean {
|
||||
return run.status === "queued" || run.status === "running";
|
||||
}
|
||||
|
||||
function commandRunCompletionLabel(run: TerminalCommandRun): string {
|
||||
if (run.status === "succeeded") return `Command succeeded${run.exitCode === undefined ? "" : ` with exit code ${String(run.exitCode)}`}`;
|
||||
return `Command failed${run.exitCode === undefined ? "" : ` with exit code ${String(run.exitCode)}`}`;
|
||||
}
|
||||
|
||||
function parseServerMessage(data: string): ServerTerminalMessage {
|
||||
const value: unknown = JSON.parse(data);
|
||||
if (!isRecord(value)) return { type: "error", message: "Invalid terminal message" };
|
||||
|
||||
@@ -17,7 +17,9 @@ export class WorkspaceList extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
|
||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@state() private openMenuWorkspaceId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
@@ -86,6 +88,7 @@ export class WorkspaceList extends LitElement {
|
||||
<span class="workspace-primary">
|
||||
${this.renderActivity(workspace)}
|
||||
<span class="workspace-primary-label">${label}</span>
|
||||
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
|
||||
</span>
|
||||
${items.length === 0 ? null : html`
|
||||
<small class="workspace-secondary">
|
||||
@@ -110,6 +113,7 @@ export class WorkspaceList extends LitElement {
|
||||
>⋯</button>
|
||||
${open ? html`
|
||||
<div class="action-menu-panel workspace-menu-panel" id=${menuId} style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
${this.renderWorkspaceActions(workspace)}
|
||||
${this.renderWorkspaceDetails(label, items, workspace)}
|
||||
</div>
|
||||
` : null}
|
||||
@@ -117,6 +121,16 @@ export class WorkspaceList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspaceActions(workspace: Workspace): TemplateResult | undefined {
|
||||
if (!canDeleteWorkspace(workspace)) return undefined;
|
||||
const deleting = this.isDeleting(workspace);
|
||||
return html`
|
||||
<div class="workspace-menu-actions">
|
||||
<button class="danger" title=${deleting ? "Workspace deletion in progress" : "Delete workspace"} ?disabled=${deleting} @click=${() => { this.delete(workspace); }}>${deleting ? "Deleting…" : "Delete workspace"}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspaceDetails(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult {
|
||||
return html`
|
||||
<dl class="workspace-menu-details">
|
||||
@@ -138,6 +152,16 @@ export class WorkspaceList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private delete(workspace: Workspace): void {
|
||||
if (this.isDeleting(workspace)) return;
|
||||
this.openMenuWorkspaceId = undefined;
|
||||
this.onDelete?.(workspace);
|
||||
}
|
||||
|
||||
private isDeleting(workspace: Workspace): boolean {
|
||||
return this.deletingWorkspaceIds.includes(workspace.id);
|
||||
}
|
||||
|
||||
private toggleMenu(workspaceId: string, target: EventTarget | null): void {
|
||||
if (this.openMenuWorkspaceId === workspaceId) {
|
||||
this.openMenuWorkspaceId = undefined;
|
||||
@@ -168,6 +192,10 @@ function workspacePrimaryLabel(workspace: Workspace): string {
|
||||
return `${workspace.branch ?? workspace.label}${workspace.isMain ? " · main" : ""}`;
|
||||
}
|
||||
|
||||
function canDeleteWorkspace(workspace: Workspace): boolean {
|
||||
return workspace.isGitWorktree && !workspace.isMain;
|
||||
}
|
||||
|
||||
function workspaceMenuId(workspaceId: string): string {
|
||||
return `workspace-menu-${workspaceId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
}
|
||||
|
||||
@@ -207,8 +207,12 @@ export const listStyles = css`
|
||||
.workspace-primary { min-width: 0; display: flex; align-items: baseline; gap: 6px; }
|
||||
.workspace-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; }
|
||||
.workspace-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-status { flex: 0 0 auto; color: var(--pi-warning); font-size: 12px; }
|
||||
.workspace-secondary { margin-top: 3px; }
|
||||
.workspace-menu-panel { width: max-content; min-width: min(120px, calc(100vw - 16px)); padding: 8px; }
|
||||
.workspace-menu-actions { margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
.workspace-menu-actions button.danger { color: var(--pi-danger); }
|
||||
.workspace-menu-actions button.danger:hover, .workspace-menu-actions button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
.workspace-menu-details { display: grid; gap: 6px; margin: 0; }
|
||||
.workspace-detail-row { display: grid; grid-template-columns: minmax(58px, max-content) minmax(0, 1fr); gap: 8px; align-items: baseline; }
|
||||
.workspace-detail-row dt { color: var(--pi-muted); font-size: 12px; white-space: normal; }
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { api, type Project, type Workspace } from "../api";
|
||||
import { api as defaultApi, type Project, type Workspace } from "../api";
|
||||
import { resetWorkspaceScopedState } from "../appState";
|
||||
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
||||
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
|
||||
import type { SessionController } from "./sessionController";
|
||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
||||
|
||||
export interface WorkspaceControllerDependencies {
|
||||
api?: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
||||
}
|
||||
|
||||
export class WorkspaceController {
|
||||
private readonly api: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
private readonly setState: SetState,
|
||||
private readonly updateUrl: UpdateUrl,
|
||||
private readonly sessions: SessionController,
|
||||
private readonly sessions: Pick<SessionController, "clearActiveSession" | "preferredSession" | "selectSession">,
|
||||
private readonly workspaceSelection: WorkspaceSelectionMemory = new InMemoryWorkspaceSelectionMemory(),
|
||||
) {}
|
||||
deps: WorkspaceControllerDependencies = {},
|
||||
) {
|
||||
this.api = deps.api ?? defaultApi;
|
||||
}
|
||||
|
||||
clearSelection(options?: { updateUrl?: boolean | undefined }) {
|
||||
this.sessions.clearActiveSession();
|
||||
@@ -30,7 +39,7 @@ export class WorkspaceController {
|
||||
this.sessions.clearActiveSession();
|
||||
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
|
||||
try {
|
||||
const workspaces = await api.workspaces(project.id);
|
||||
const workspaces = await this.api.workspaces(project.id);
|
||||
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false });
|
||||
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
|
||||
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
|
||||
@@ -45,7 +54,7 @@ export class WorkspaceController {
|
||||
this.sessions.clearActiveSession();
|
||||
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
|
||||
try {
|
||||
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path));
|
||||
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path));
|
||||
this.setState({ sessions });
|
||||
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
||||
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
||||
@@ -54,4 +63,38 @@ export class WorkspaceController {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProjectWorkspaces(projectId: string): Promise<Workspace[]> {
|
||||
const project = this.getState().projects.find((candidate) => candidate.id === projectId);
|
||||
if (project === undefined) throw new Error("Project not found");
|
||||
const workspaces = await this.api.workspaces(project.id);
|
||||
this.applyProjectWorkspaces(project.id, workspaces);
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
async refreshAfterWorkspaceDeleted(projectId: string, workspaceId: string): Promise<void> {
|
||||
const workspaces = await this.refreshProjectWorkspaces(projectId);
|
||||
const state = this.getState();
|
||||
if (state.selectedProject?.id !== projectId || state.selectedWorkspace?.id !== workspaceId) return;
|
||||
|
||||
const fallback = selectFallbackWorkspace(workspaces);
|
||||
if (fallback !== undefined) await this.selectWorkspace(fallback);
|
||||
else this.clearSelection();
|
||||
}
|
||||
|
||||
private applyProjectWorkspaces(projectId: string, workspaces: Workspace[]): void {
|
||||
const state = this.getState();
|
||||
const workspacesByProjectId = { ...state.workspacesByProjectId, [projectId]: workspaces };
|
||||
if (state.selectedProject?.id === projectId) this.setState({ workspaces, workspacesByProjectId });
|
||||
else this.setState({ workspacesByProjectId });
|
||||
}
|
||||
}
|
||||
|
||||
export function canDeleteWorkspace(workspace: Workspace | undefined): boolean {
|
||||
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain;
|
||||
}
|
||||
|
||||
function selectFallbackWorkspace(workspaces: Workspace[]): Workspace | undefined {
|
||||
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isSessionActive } from "../../../../shared/activity";
|
||||
import type { AppState } from "../../appState";
|
||||
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
|
||||
import type { PluginAction } from "../types";
|
||||
|
||||
export function createCoreActions(): PluginAction[] {
|
||||
@@ -116,6 +117,14 @@ export function createCoreActions(): PluginAction[] {
|
||||
enabled: hasWorkspace,
|
||||
run: (context) => context.state.workspaceTool === "core:workspace.git" && context.state.selectedWorkspace?.isGitRepo === true ? context.refreshGit() : context.refreshFiles(),
|
||||
},
|
||||
{
|
||||
id: "workspace.delete",
|
||||
title: "Delete Workspace",
|
||||
description: "Remove the selected Git worktree",
|
||||
group: "Workspace",
|
||||
enabled: hasDeletableWorkspace,
|
||||
run: (context) => context.deleteWorkspace(),
|
||||
},
|
||||
{
|
||||
id: "session.start",
|
||||
title: "Start Session",
|
||||
@@ -150,3 +159,8 @@ function hasWorkspace(context: { state: AppState }): boolean {
|
||||
function hasGitWorkspace(context: { state: AppState }): boolean {
|
||||
return context.state.selectedWorkspace?.isGitRepo === true;
|
||||
}
|
||||
|
||||
function hasDeletableWorkspace(context: { state: AppState }): boolean {
|
||||
const workspace = context.state.selectedWorkspace;
|
||||
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
const calls: string[] = [];
|
||||
const context: PluginRuntimeContext = {
|
||||
state: { ...initialAppState(), ...statePatch },
|
||||
piWebInternal: {
|
||||
terminalCommandRuns: {
|
||||
runCommand: vi.fn(),
|
||||
listCommandRuns: vi.fn(),
|
||||
getCommandRun: vi.fn(),
|
||||
open: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`terminal.open:${options?.terminalId ?? ""}`); }),
|
||||
},
|
||||
},
|
||||
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
|
||||
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
|
||||
addProject: vi.fn(() => { calls.push("addProject"); }),
|
||||
@@ -23,6 +31,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
refreshGit: vi.fn(() => { calls.push("refreshGit"); }),
|
||||
refreshAppData: vi.fn(() => { calls.push("refreshAppData"); }),
|
||||
reloadPage: vi.fn(() => { calls.push("reloadPage"); }),
|
||||
deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }),
|
||||
startSession: vi.fn(() => { calls.push("startSession"); }),
|
||||
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
|
||||
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
|
||||
@@ -72,6 +81,21 @@ describe("PluginRegistry", () => {
|
||||
expect(inactive.find((action) => action.id === "core:view.terminal")?.enabled).toBe(false);
|
||||
expect(active.find((action) => action.id === "core:view.files")?.enabled).toBe(true);
|
||||
expect(active.find((action) => action.id === "core:view.terminal")?.enabled).toBe(true);
|
||||
expect(active.find((action) => action.id === "core:workspace.delete")?.enabled).toBe(false);
|
||||
|
||||
const deletable = registry.getActions(createContext({ selectedWorkspace: testWorkspace({ isMain: false, isGitWorktree: true }) }).context);
|
||||
expect(deletable.find((action) => action.id === "core:workspace.delete")?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("routes workspace delete through the runtime context", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const { context, calls } = createContext({ selectedWorkspace: testWorkspace({ isMain: false, isGitWorktree: true }) });
|
||||
const action = registry.getActions(context).find((candidate) => candidate.id === "core:workspace.delete");
|
||||
|
||||
if (action !== undefined) void action.run();
|
||||
|
||||
expect(calls).toEqual(["deleteWorkspace"]);
|
||||
});
|
||||
|
||||
it("routes refresh current to the active core workspace panel", () => {
|
||||
@@ -205,8 +229,8 @@ describe("PluginRegistry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function testWorkspace(): Workspace {
|
||||
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
|
||||
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
|
||||
}
|
||||
|
||||
function testThemeTokens(): ThemeTokens {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { html } from "lit";
|
||||
import type { AppState } from "../appState";
|
||||
import type { Workspace } from "../api";
|
||||
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
|
||||
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
|
||||
|
||||
const idPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||
const pluginRuntimeScopes = new WeakMap<PluginRuntimeContext, (pluginId: string) => PluginRuntimeContext>();
|
||||
const workspacePanelScopes = new WeakMap<WorkspacePanelContext, (pluginId: string) => WorkspacePanelContext>();
|
||||
|
||||
type RegisteredPluginAction = Omit<PluginAction, "id"> & {
|
||||
id: QualifiedContributionId;
|
||||
@@ -40,13 +42,14 @@ export class PluginRegistry {
|
||||
|
||||
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
|
||||
return this.actions.map((action) => {
|
||||
const enabled = action.enabled?.(context);
|
||||
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
|
||||
const enabled = action.enabled?.(scopedContext);
|
||||
const qualified: QualifiedPluginAction = {
|
||||
id: action.id,
|
||||
pluginId: action.pluginId,
|
||||
localId: action.localId,
|
||||
title: action.title,
|
||||
run: () => action.run(context),
|
||||
run: () => action.run(scopedContext),
|
||||
};
|
||||
if (action.description !== undefined) qualified.description = action.description;
|
||||
if (action.shortcut !== undefined) qualified.shortcut = action.shortcut;
|
||||
@@ -85,7 +88,15 @@ export class PluginRegistry {
|
||||
|
||||
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
|
||||
const id = this.qualify(pluginId, panel.id);
|
||||
return { ...panel, id, pluginId, localId: panel.id };
|
||||
const badge = panel.badge;
|
||||
return {
|
||||
...panel,
|
||||
id,
|
||||
pluginId,
|
||||
localId: panel.id,
|
||||
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
|
||||
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
|
||||
};
|
||||
}
|
||||
|
||||
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution): QualifiedWorkspaceLabelContribution {
|
||||
@@ -131,3 +142,21 @@ export class PluginRegistry {
|
||||
if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext {
|
||||
return pluginRuntimeScopes.get(context)?.(pluginId) ?? context;
|
||||
}
|
||||
|
||||
function workspacePanelContextFor(context: WorkspacePanelContext, pluginId: string): WorkspacePanelContext {
|
||||
return workspacePanelScopes.get(context)?.(pluginId) ?? context;
|
||||
}
|
||||
|
||||
export function installPluginRuntimeScope(context: PluginRuntimeContext, scope: (pluginId: string) => PluginRuntimeContext): PluginRuntimeContext {
|
||||
pluginRuntimeScopes.set(context, scope);
|
||||
return context;
|
||||
}
|
||||
|
||||
export function installWorkspacePanelScope(context: WorkspacePanelContext, scope: (pluginId: string) => WorkspacePanelContext): WorkspacePanelContext {
|
||||
workspacePanelScopes.set(context, scope);
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { AppAction } from "../actions";
|
||||
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
|
||||
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
|
||||
export type PluginId = string;
|
||||
@@ -37,8 +37,20 @@ export interface PluginContributions {
|
||||
themePairs?: ThemePairContribution[];
|
||||
}
|
||||
|
||||
export interface PiWebInternalRuntimeContext {
|
||||
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
|
||||
}
|
||||
|
||||
export interface TerminalCommandRunsInternalRuntime {
|
||||
runCommand(input: RunTerminalCommandInput): Promise<TerminalCommandRunHandle>;
|
||||
listCommandRuns(filter?: TerminalCommandRunFilter): Promise<TerminalCommandRun[]>;
|
||||
getCommandRun(runId: string): Promise<TerminalCommandRun | undefined>;
|
||||
open(options?: { terminalId?: string | undefined }): void;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeContext {
|
||||
state: AppState;
|
||||
piWebInternal?: PiWebInternalRuntimeContext;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
@@ -52,6 +64,7 @@ export interface PluginRuntimeContext {
|
||||
refreshGit: () => void | Promise<void>;
|
||||
refreshAppData: () => void | Promise<void>;
|
||||
reloadPage: () => void;
|
||||
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
|
||||
startSession: () => void | Promise<void>;
|
||||
archiveSession: () => void | Promise<void>;
|
||||
stopActiveWork: () => void | Promise<void>;
|
||||
@@ -80,6 +93,7 @@ export interface WorkspacePanelVisibilityContext {
|
||||
export interface WorkspacePanelContext {
|
||||
workspace: Workspace;
|
||||
state: AppState;
|
||||
piWebInternal?: PiWebInternalRuntimeContext;
|
||||
fileTree: FileTreeEntry[];
|
||||
expandedDirs: Record<string, FileTreeEntry[]>;
|
||||
selectedFilePath: string | undefined;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RunTerminalCommandInput, TerminalCommandRun, Workspace } from "../api";
|
||||
import { createTerminalCommandRunsRuntime } from "./terminalRuntime";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w1",
|
||||
projectId: "p1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
const runningRun: TerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: "plugin",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
terminalId: "t1",
|
||||
title: "Build",
|
||||
command: "npm run build",
|
||||
status: "running",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const succeededRun: TerminalCommandRun = { ...runningRun, status: "succeeded", exitCode: 0, completedAt: "2026-05-25T00:00:01.000Z" };
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("terminal runtime", () => {
|
||||
it("starts commands with the assigned origin and opens the returned terminal when requested", async () => {
|
||||
const openTerminal = vi.fn();
|
||||
const api = {
|
||||
runTerminalCommand: vi.fn((origin: string, input: RunTerminalCommandInput) => {
|
||||
void origin;
|
||||
void input;
|
||||
return Promise.resolve(succeededRun);
|
||||
}),
|
||||
listCommandRuns: vi.fn(),
|
||||
getCommandRun: vi.fn(),
|
||||
};
|
||||
const runtime = createTerminalCommandRunsRuntime("actions", { api, openTerminal });
|
||||
|
||||
const handle = await runtime.runCommand({ workspace, title: "Build", command: "npm run build", open: true });
|
||||
|
||||
expect(api.runTerminalCommand).toHaveBeenCalledWith("actions", { workspace, title: "Build", command: "npm run build", open: true });
|
||||
expect(openTerminal).toHaveBeenCalledWith(workspace, { terminalId: "t1" });
|
||||
await expect(handle.completed).resolves.toEqual(succeededRun);
|
||||
});
|
||||
|
||||
it("polls command-run records until completion", async () => {
|
||||
vi.useFakeTimers();
|
||||
const api = {
|
||||
runTerminalCommand: vi.fn(() => Promise.resolve(runningRun)),
|
||||
listCommandRuns: vi.fn(),
|
||||
getCommandRun: vi.fn(() => Promise.resolve(succeededRun)),
|
||||
};
|
||||
const runtime = createTerminalCommandRunsRuntime("core", {
|
||||
api,
|
||||
openTerminal: vi.fn(),
|
||||
pollIntervalMs: 25,
|
||||
setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout),
|
||||
clearTimeout: (id) => { globalThis.clearTimeout(id); },
|
||||
});
|
||||
|
||||
const handle = await runtime.runCommand({ workspace, title: "Build", command: "npm run build" });
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(handle.completed).resolves.toEqual(succeededRun);
|
||||
expect(api.getCommandRun).toHaveBeenCalledWith("run1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { terminalsApi as defaultApi, type RunTerminalCommandInput, type TerminalCommandRun, type TerminalCommandRunFilter, type Workspace } from "../api";
|
||||
import type { TerminalCommandRunsInternalRuntime } from "../plugins/types";
|
||||
|
||||
type TimerId = ReturnType<typeof globalThis.setTimeout>;
|
||||
type SetTimer = (handler: () => void, timeout: number) => TimerId;
|
||||
type ClearTimer = (id: TimerId) => void;
|
||||
|
||||
export interface TerminalCommandRunsRuntimeDependencies {
|
||||
api?: Pick<typeof defaultApi, "runTerminalCommand" | "listCommandRuns" | "getCommandRun">;
|
||||
openTerminal: (workspace: Workspace | undefined, options?: { terminalId?: string | undefined }) => void | Promise<void>;
|
||||
pollIntervalMs?: number;
|
||||
setTimeout?: SetTimer;
|
||||
clearTimeout?: ClearTimer;
|
||||
}
|
||||
|
||||
export function createTerminalCommandRunsRuntime(origin: string, deps: TerminalCommandRunsRuntimeDependencies): TerminalCommandRunsInternalRuntime {
|
||||
const api = deps.api ?? defaultApi;
|
||||
const pollIntervalMs = deps.pollIntervalMs ?? 1000;
|
||||
const setTimer = deps.setTimeout ?? defaultSetTimeout();
|
||||
const clearTimer = deps.clearTimeout ?? defaultClearTimeout();
|
||||
|
||||
return {
|
||||
async runCommand(input: RunTerminalCommandInput) {
|
||||
const run = await api.runTerminalCommand(origin, input);
|
||||
if (input.open === true) void deps.openTerminal(input.workspace, { terminalId: run.terminalId });
|
||||
return { run, completed: waitForCommandRunCompletion(run, api, pollIntervalMs, setTimer, clearTimer) };
|
||||
},
|
||||
listCommandRuns: (filter?: TerminalCommandRunFilter) => api.listCommandRuns(filter),
|
||||
getCommandRun: (runId: string) => api.getCommandRun(runId),
|
||||
open: (options?: { terminalId?: string | undefined }) => { void deps.openTerminal(undefined, options); },
|
||||
};
|
||||
}
|
||||
|
||||
function waitForCommandRunCompletion(
|
||||
initialRun: TerminalCommandRun,
|
||||
api: Pick<typeof defaultApi, "getCommandRun">,
|
||||
pollIntervalMs: number,
|
||||
setTimer: SetTimer,
|
||||
clearTimer: ClearTimer,
|
||||
): Promise<TerminalCommandRun> {
|
||||
if (isTerminalCommandRunFinal(initialRun)) return Promise.resolve(initialRun);
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: TimerId | undefined;
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: TerminalCommandRun) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer !== undefined) clearTimer(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer !== undefined) clearTimer(timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
const poll = () => {
|
||||
void api.getCommandRun(initialRun.id).then((run) => {
|
||||
if (run !== undefined && isTerminalCommandRunFinal(run)) {
|
||||
finish(run);
|
||||
return;
|
||||
}
|
||||
timer = setTimer(poll, pollIntervalMs);
|
||||
}).catch(fail);
|
||||
};
|
||||
|
||||
timer = setTimer(poll, pollIntervalMs);
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminalCommandRunFinal(run: TerminalCommandRun): boolean {
|
||||
return run.status === "succeeded" || run.status === "failed";
|
||||
}
|
||||
|
||||
function defaultSetTimeout(): SetTimer {
|
||||
return (handler, timeout) => globalThis.setTimeout(handler, timeout);
|
||||
}
|
||||
|
||||
function defaultClearTimeout(): ClearTimer {
|
||||
return (id) => { globalThis.clearTimeout(id); };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TerminalCommandRun, Workspace } from "./api";
|
||||
import { isWorkspaceDeletionPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, workspaceDeleteOperation, workspaceDeletionMetadata } from "./workspaceDeletion";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w1",
|
||||
projectId: "p1",
|
||||
path: "/repo/worktree",
|
||||
label: "worktree",
|
||||
isMain: false,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
function run(id: string, workspaceId: string, createdAt: string, status: TerminalCommandRun["status"]): TerminalCommandRun {
|
||||
return {
|
||||
id,
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "main",
|
||||
terminalId: `t-${id}`,
|
||||
title: "Delete workspace",
|
||||
command: "git worktree remove '/repo/worktree'",
|
||||
status,
|
||||
createdAt,
|
||||
metadata: { "pi.operation": workspaceDeleteOperation, "target.workspaceId": workspaceId, "target.workspacePath": "/repo/worktree" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace deletion state", () => {
|
||||
it("builds command-run metadata for workspace deletion", () => {
|
||||
expect(workspaceDeletionMetadata(workspace)).toEqual({
|
||||
"pi.operation": "workspace.delete",
|
||||
"target.workspaceId": "w1",
|
||||
"target.workspacePath": "/repo/worktree",
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks the latest deletion run per target workspace", () => {
|
||||
expect(latestWorkspaceDeletionRuns([
|
||||
run("old", "w1", "2026-05-25T00:00:00.000Z", "failed"),
|
||||
run("new", "w1", "2026-05-25T00:00:01.000Z", "running"),
|
||||
run("other", "w2", "2026-05-25T00:00:00.000Z", "running"),
|
||||
])).toMatchObject({
|
||||
w1: { id: "new", status: "running" },
|
||||
w2: { id: "other", status: "running" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports pending workspace deletions for disabling repeated actions", () => {
|
||||
const state = { workspaceDeletionRuns: { w1: run("new", "w1", "2026-05-25T00:00:01.000Z", "running") } };
|
||||
|
||||
expect(isWorkspaceDeletionPending(state, workspace)).toBe(true);
|
||||
expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AppState } from "./appState";
|
||||
import type { TerminalCommandRun, Workspace } from "./api";
|
||||
|
||||
export const workspaceDeleteOperation = "workspace.delete";
|
||||
export const workspaceDeleteOperationMetadataKey = "pi.operation";
|
||||
export const targetWorkspaceIdMetadataKey = "target.workspaceId";
|
||||
export const targetWorkspacePathMetadataKey = "target.workspacePath";
|
||||
|
||||
export function workspaceDeletionMetadata(workspace: Workspace): Record<string, string> {
|
||||
return {
|
||||
[workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation,
|
||||
[targetWorkspaceIdMetadataKey]: workspace.id,
|
||||
[targetWorkspacePathMetadataKey]: workspace.path,
|
||||
};
|
||||
}
|
||||
|
||||
export function workspaceDeletionRunFilter(projectId?: string): { projectId?: string; metadata: Record<string, string> } {
|
||||
return {
|
||||
...(projectId === undefined ? {} : { projectId }),
|
||||
metadata: { [workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation },
|
||||
};
|
||||
}
|
||||
|
||||
export function latestWorkspaceDeletionRuns(runs: TerminalCommandRun[]): Record<string, TerminalCommandRun> {
|
||||
const latest: Record<string, TerminalCommandRun> = {};
|
||||
for (const run of runs) {
|
||||
const workspaceId = targetWorkspaceIdForRun(run);
|
||||
if (workspaceId === undefined) continue;
|
||||
const current = latest[workspaceId];
|
||||
if (current === undefined || run.createdAt.localeCompare(current.createdAt) >= 0) latest[workspaceId] = run;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function pendingWorkspaceDeletionIds(runsByWorkspaceId: Record<string, TerminalCommandRun>): string[] {
|
||||
return Object.entries(runsByWorkspaceId)
|
||||
.filter(([, run]) => isWorkspaceDeletionRunPending(run))
|
||||
.map(([workspaceId]) => workspaceId);
|
||||
}
|
||||
|
||||
export function isWorkspaceDeletionPending(state: Pick<AppState, "workspaceDeletionRuns">, workspace: Workspace | undefined): boolean {
|
||||
if (workspace === undefined) return false;
|
||||
const run = state.workspaceDeletionRuns[workspace.id];
|
||||
return run !== undefined && isWorkspaceDeletionRunPending(run);
|
||||
}
|
||||
|
||||
export function targetWorkspaceIdForRun(run: TerminalCommandRun): string | undefined {
|
||||
return run.metadata[targetWorkspaceIdMetadataKey];
|
||||
}
|
||||
|
||||
export function targetWorkspacePathForRun(run: TerminalCommandRun): string | undefined {
|
||||
return run.metadata[targetWorkspacePathMetadataKey];
|
||||
}
|
||||
|
||||
export function isWorkspaceDeletionRunPending(run: TerminalCommandRun): boolean {
|
||||
return run.status === "queued" || run.status === "running";
|
||||
}
|
||||
|
||||
export function isWorkspaceDeletionRun(run: TerminalCommandRun): boolean {
|
||||
return run.metadata[workspaceDeleteOperationMetadataKey] === workspaceDeleteOperation;
|
||||
}
|
||||
@@ -27,6 +27,16 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue", async (request, reply) => {
|
||||
try {
|
||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => {
|
||||
try {
|
||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
@@ -37,6 +47,51 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>("/api/projects/:projectId/workspaces/:workspaceId/terminal-command-runs", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
|
||||
origin: request.body.origin,
|
||||
projectId: request.params.projectId,
|
||||
workspaceId: request.params.workspaceId,
|
||||
cwd: context.root,
|
||||
title: request.body.title,
|
||||
command: request.body.command,
|
||||
metadata: request.body.metadata ?? {},
|
||||
}, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Querystring: TerminalCommandRunQuery }>("/api/terminal-command-runs", async (request, reply) => {
|
||||
try {
|
||||
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId/cancel", async (request, reply) => {
|
||||
try {
|
||||
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId", async (request, reply) => {
|
||||
try {
|
||||
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => {
|
||||
try {
|
||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
@@ -49,6 +104,32 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
||||
});
|
||||
}
|
||||
|
||||
interface TerminalCommandRunRequest {
|
||||
origin: string;
|
||||
title: string;
|
||||
command: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface TerminalCommandRunQuery {
|
||||
projectId?: string;
|
||||
workspaceId?: string;
|
||||
terminalId?: string;
|
||||
statuses?: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
function terminalCommandRunQuery(filter: TerminalCommandRunQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.projectId !== undefined) params.set("projectId", filter.projectId);
|
||||
if (filter.workspaceId !== undefined) params.set("workspaceId", filter.workspaceId);
|
||||
if (filter.terminalId !== undefined) params.set("terminalId", filter.terminalId);
|
||||
if (filter.statuses !== undefined) params.set("statuses", filter.statuses);
|
||||
if (filter.metadata !== undefined) params.set("metadata", filter.metadata);
|
||||
const query = params.toString();
|
||||
return query === "" ? "" : `?${query}`;
|
||||
}
|
||||
|
||||
async function proxyJson(daemon: SessionDaemonClient, method: string, path: string, body: unknown, reply: FastifyReply): Promise<unknown> {
|
||||
const upstream = await daemon.request(method, path, body);
|
||||
reply.code(upstream.statusCode);
|
||||
|
||||
@@ -2,7 +2,8 @@ import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import type { TerminalInfo } from "./terminalService.js";
|
||||
import type { TerminalCommandRun, TerminalCommandRunFilter } from "../../shared/apiTypes.js";
|
||||
import type { RunTerminalCommandOptions, TerminalInfo } from "./terminalService.js";
|
||||
import { registerTerminalRoutes, type TerminalRouteService } from "./terminalRoutes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -20,7 +21,7 @@ afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("terminal socket routes", () => {
|
||||
describe("terminal routes", () => {
|
||||
it("applies the initial socket size before attaching and replaying output", async () => {
|
||||
const socket = new WebSocket(`${serverUrl(app)}/terminals/t1/socket?cols=120.9&rows=40.2`);
|
||||
|
||||
@@ -29,10 +30,37 @@ describe("terminal socket routes", () => {
|
||||
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("creates and lists terminal command runs with filters", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/terminal-command-runs",
|
||||
payload: { origin: "core", projectId: "p1", workspaceId: "w1", cwd: "/repo", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
expect(createResponse.json<TerminalCommandRun>()).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" });
|
||||
|
||||
const listResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?projectId=p1&statuses=running&metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": "test" }))}` });
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json<TerminalCommandRun[]>()).toHaveLength(1);
|
||||
expect(terminals.filters).toEqual([{ projectId: "p1", statuses: ["running"], metadata: { "pi.operation": "test" } }]);
|
||||
|
||||
const cancelResponse = await app.inject({ method: "POST", url: "/terminal-command-runs/run1/cancel" });
|
||||
expect(cancelResponse.statusCode).toBe(200);
|
||||
expect(terminals.events).toContain("cancel:run1");
|
||||
|
||||
const continueResponse = await app.inject({ method: "POST", url: "/terminals/t-run/continue" });
|
||||
expect(continueResponse.statusCode).toBe(200);
|
||||
expect(terminals.events).toContain("continue:t-run");
|
||||
});
|
||||
});
|
||||
|
||||
class FakeTerminals implements TerminalRouteService {
|
||||
readonly events: string[] = [];
|
||||
readonly filters: TerminalCommandRunFilter[] = [];
|
||||
private readonly commandRuns = new Map<string, TerminalCommandRun>();
|
||||
|
||||
list(cwd: string): TerminalInfo[] {
|
||||
void cwd;
|
||||
@@ -68,6 +96,49 @@ class FakeTerminals implements TerminalRouteService {
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
this.events.push(`resize:${id}:${String(cols)}x${String(rows)}`);
|
||||
}
|
||||
|
||||
continue(id: string): TerminalInfo {
|
||||
this.events.push(`continue:${id}`);
|
||||
return { id, cwd: "/repo", name: "Shell 1", createdAt: "2026-05-13T00:00:00.000Z", exited: false };
|
||||
}
|
||||
|
||||
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
|
||||
const run: TerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: options.origin,
|
||||
projectId: options.projectId,
|
||||
workspaceId: options.workspaceId,
|
||||
terminalId: "t-run",
|
||||
title: options.title,
|
||||
command: options.command,
|
||||
status: "running",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
metadata: routeMetadata(options.metadata),
|
||||
};
|
||||
this.commandRuns.set(run.id, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
listCommandRuns(filter: TerminalCommandRunFilter = {}): TerminalCommandRun[] {
|
||||
this.filters.push(filter);
|
||||
return [...this.commandRuns.values()];
|
||||
}
|
||||
|
||||
getCommandRun(runId: string): TerminalCommandRun | undefined {
|
||||
return this.commandRuns.get(runId);
|
||||
}
|
||||
|
||||
cancelCommandRun(runId: string): TerminalCommandRun {
|
||||
const run = this.commandRuns.get(runId);
|
||||
if (run === undefined) throw new Error("Terminal command run not found");
|
||||
this.events.push(`cancel:${runId}`);
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
function routeMetadata(value: unknown): Record<string, string> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
}
|
||||
|
||||
function serverUrl(instance: FastifyInstance): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { RawData } from "ws";
|
||||
import type { TerminalInfo } from "./terminalService.js";
|
||||
import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunStatus } from "../../shared/apiTypes.js";
|
||||
import type { RunTerminalCommandOptions, TerminalInfo } from "./terminalService.js";
|
||||
import { parseTerminalSize } from "./terminalSize.js";
|
||||
|
||||
export interface TerminalRouteService {
|
||||
@@ -10,6 +11,11 @@ export interface TerminalRouteService {
|
||||
attach(id: string, handlers: { output: (data: string, replay: boolean) => void; exit: (exitCode: number | undefined) => void }): () => void;
|
||||
write(id: string, data: string): void;
|
||||
resize(id: string, cols: number, rows: number): void;
|
||||
continue(id: string): TerminalInfo;
|
||||
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun;
|
||||
listCommandRuns(filter?: TerminalCommandRunFilter): TerminalCommandRun[];
|
||||
getCommandRun(runId: string): TerminalCommandRun | undefined;
|
||||
cancelCommandRun(runId: string): TerminalCommandRun;
|
||||
}
|
||||
|
||||
export function registerTerminalRoutes(app: FastifyInstance, terminals: TerminalRouteService, prefix = ""): void {
|
||||
@@ -26,13 +32,51 @@ export function registerTerminalRoutes(app: FastifyInstance, terminals: Terminal
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: RunTerminalCommandOptions }>(`${prefix}/terminal-command-runs`, (request, reply) => {
|
||||
try {
|
||||
return terminals.runCommand(request.body);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, (request, reply) => {
|
||||
try {
|
||||
return terminals.listCommandRuns(parseCommandRunFilter(request.query));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, (request, reply) => {
|
||||
try {
|
||||
return terminals.cancelCommandRun(request.params.runId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, (request, reply) => {
|
||||
const run = terminals.getCommandRun(request.params.runId);
|
||||
if (run === undefined) return reply.code(404).send({ error: "Terminal command run not found" });
|
||||
return run;
|
||||
});
|
||||
|
||||
app.post<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId/continue`, (request, reply) => {
|
||||
try {
|
||||
return terminals.continue(request.params.terminalId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId`, (request) => {
|
||||
terminals.close(request.params.terminalId);
|
||||
return { closed: true };
|
||||
});
|
||||
|
||||
app.get<{ Params: { terminalId: string }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/terminals/:terminalId/socket`, { websocket: true }, (socket, request) => {
|
||||
let detach: (() => void) | undefined;
|
||||
let detach: () => void = () => undefined;
|
||||
try {
|
||||
const initialSize = parseTerminalSize(request.query.cols, request.query.rows);
|
||||
if (initialSize !== undefined) terminals.resize(request.params.terminalId, initialSize.cols, initialSize.rows);
|
||||
@@ -64,6 +108,40 @@ type ClientTerminalMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; cols: number; rows: number };
|
||||
|
||||
interface TerminalCommandRunQuery {
|
||||
projectId?: string;
|
||||
workspaceId?: string;
|
||||
terminalId?: string;
|
||||
statuses?: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
function parseCommandRunFilter(query: TerminalCommandRunQuery): TerminalCommandRunFilter {
|
||||
const metadata = query.metadata === undefined || query.metadata === "" ? undefined : parseMetadataFilter(query.metadata);
|
||||
const statuses = query.statuses === undefined || query.statuses === "" ? undefined : query.statuses.split(",").filter((status) => status !== "").map(parseCommandRunStatus);
|
||||
return {
|
||||
...(query.projectId === undefined ? {} : { projectId: query.projectId }),
|
||||
...(query.workspaceId === undefined ? {} : { workspaceId: query.workspaceId }),
|
||||
...(query.terminalId === undefined ? {} : { terminalId: query.terminalId }),
|
||||
...(statuses === undefined ? {} : { statuses }),
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCommandRunStatus(value: string): TerminalCommandRunStatus {
|
||||
if (value !== "queued" && value !== "running" && value !== "succeeded" && value !== "failed") throw new Error(`Invalid command run status: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseMetadataFilter(value: string): Record<string, string> {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!isRecord(parsed) || Array.isArray(parsed)) throw new Error("metadata filter must be an object");
|
||||
return Object.fromEntries(Object.entries(parsed).map(([key, metadataValue]) => {
|
||||
if (typeof metadataValue !== "string") throw new Error(`metadata filter value must be a string: ${key}`);
|
||||
return [key, metadataValue];
|
||||
}));
|
||||
}
|
||||
|
||||
function parseClientMessage(data: RawData): ClientTerminalMessage {
|
||||
const value: unknown = JSON.parse(rawDataToString(data));
|
||||
if (!isRecord(value) || typeof value["type"] !== "string") throw new Error("Invalid terminal message");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalService } from "./terminalService";
|
||||
|
||||
describe("TerminalService command runs", () => {
|
||||
it("tracks dedicated terminal command runs through completion", async () => {
|
||||
const service = new TerminalService();
|
||||
try {
|
||||
const run = service.runCommand({
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
cwd: process.cwd(),
|
||||
title: "Test command",
|
||||
command: "printf 'hello'",
|
||||
metadata: { "pi.operation": "test" },
|
||||
});
|
||||
|
||||
expect(run).toMatchObject({ status: "running", origin: "core", projectId: "p1", workspaceId: "w1", metadata: { "pi.operation": "test" } });
|
||||
expect(service.get(run.terminalId)).toMatchObject({ commandRunId: run.id });
|
||||
expect(service.listCommandRuns({ metadata: { "pi.operation": "test" } })).toHaveLength(1);
|
||||
|
||||
const output = await terminalExit(service, run.terminalId);
|
||||
|
||||
expect(output).toContain("$ printf 'hello'");
|
||||
expect(output).toContain("hello");
|
||||
expect(service.getCommandRun(run.id)).toMatchObject({ status: "succeeded", exitCode: 0, terminalId: run.terminalId });
|
||||
expect(service.listCommandRuns({ statuses: ["succeeded"] }).map((candidate) => candidate.id)).toEqual([run.id]);
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues an exited command-run terminal as an interactive shell", async () => {
|
||||
const service = new TerminalService();
|
||||
try {
|
||||
const run = service.runCommand({
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
cwd: process.cwd(),
|
||||
title: "Done command",
|
||||
command: "true",
|
||||
});
|
||||
await terminalExit(service, run.terminalId);
|
||||
|
||||
const continued = service.continue(run.terminalId);
|
||||
|
||||
expect(continued).toMatchObject({ id: run.terminalId, exited: false });
|
||||
expect(continued.commandRunId).toBeUndefined();
|
||||
expect(service.get(run.terminalId)?.commandRunId).toBeUndefined();
|
||||
expect(await terminalReplay(service, run.terminalId)).toContain("[continued in interactive shell]");
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks failed command runs when the command exits non-zero", async () => {
|
||||
const service = new TerminalService();
|
||||
try {
|
||||
const run = service.runCommand({
|
||||
origin: "core",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
cwd: process.cwd(),
|
||||
title: "Failing command",
|
||||
command: "exit 7",
|
||||
});
|
||||
|
||||
await terminalExit(service, run.terminalId);
|
||||
|
||||
expect(service.getCommandRun(run.id)).toMatchObject({ status: "failed", exitCode: 7 });
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function terminalReplay(service: TerminalService, terminalId: string): Promise<string> {
|
||||
let output = "";
|
||||
const detach = service.attach(terminalId, {
|
||||
output: (data) => { output += data; },
|
||||
exit: () => undefined,
|
||||
});
|
||||
detach();
|
||||
return Promise.resolve(output);
|
||||
}
|
||||
|
||||
function terminalExit(service: TerminalService, terminalId: string): Promise<string> {
|
||||
const output: string[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
service.attach(terminalId, {
|
||||
output: (data) => { output.push(data); },
|
||||
exit: () => { resolve(output.join("")); },
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import type { TerminalUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunStatus, TerminalUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
@@ -14,16 +14,31 @@ export interface TerminalInfo {
|
||||
createdAt: string;
|
||||
exited: boolean;
|
||||
exitCode?: number;
|
||||
commandRunId?: string;
|
||||
}
|
||||
|
||||
export interface RunTerminalCommandOptions {
|
||||
origin: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
command: string;
|
||||
metadata?: unknown;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
interface TerminalRecord extends TerminalInfo {
|
||||
pty: pty.IPty;
|
||||
buffer: string;
|
||||
events: EventEmitter;
|
||||
commandRunId?: string;
|
||||
}
|
||||
|
||||
export class TerminalService {
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
private readonly commandRuns = new Map<string, TerminalCommandRun>();
|
||||
|
||||
constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal">) {}
|
||||
|
||||
@@ -34,45 +49,67 @@ export class TerminalService {
|
||||
}
|
||||
|
||||
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
|
||||
if (options.cwd === "") throw new Error("cwd is required");
|
||||
const id = randomUUID();
|
||||
return this.createTerminal({ ...options, shellArgs: [] });
|
||||
}
|
||||
|
||||
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
|
||||
validateCommandRunOptions(options);
|
||||
const commandRunId = randomUUID();
|
||||
const terminalId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
const terminal = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cwd: options.cwd,
|
||||
cols: options.cols ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
const requestedName = options.name?.trim();
|
||||
const record: TerminalRecord = {
|
||||
id,
|
||||
cwd: options.cwd,
|
||||
name: requestedName !== undefined && requestedName !== "" ? requestedName : `Shell ${String(this.list(options.cwd).length + 1)}`,
|
||||
const metadata = parseMetadata(options.metadata);
|
||||
const queued: TerminalCommandRun = {
|
||||
id: commandRunId,
|
||||
origin: options.origin,
|
||||
projectId: options.projectId,
|
||||
workspaceId: options.workspaceId,
|
||||
terminalId,
|
||||
title: options.title,
|
||||
command: options.command,
|
||||
status: "queued",
|
||||
createdAt,
|
||||
exited: false,
|
||||
pty: terminal,
|
||||
buffer: "",
|
||||
events: new EventEmitter(),
|
||||
metadata,
|
||||
};
|
||||
terminal.onData((data) => {
|
||||
record.buffer = trimReplayBuffer(record.buffer + data);
|
||||
record.events.emit("output", data);
|
||||
});
|
||||
terminal.onExit(({ exitCode }) => {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
record.events.emit("exit", exitCode);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.exited", terminal: info });
|
||||
});
|
||||
this.terminals.set(id, record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
const running: TerminalCommandRun = { ...queued, status: "running", startedAt: new Date().toISOString() };
|
||||
this.commandRuns.set(commandRunId, running);
|
||||
|
||||
try {
|
||||
this.createTerminal({
|
||||
id: terminalId,
|
||||
cwd: options.cwd,
|
||||
name: options.title,
|
||||
...(options.cols === undefined ? {} : { cols: options.cols }),
|
||||
...(options.rows === undefined ? {} : { rows: options.rows }),
|
||||
shellArgs: ["-lc", commandRunShellScript(options.command)],
|
||||
commandRunId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.commandRuns.delete(commandRunId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return copyCommandRun(this.commandRuns.get(commandRunId) ?? running);
|
||||
}
|
||||
|
||||
listCommandRuns(filter: TerminalCommandRunFilter = {}): TerminalCommandRun[] {
|
||||
return [...this.commandRuns.values()]
|
||||
.filter((run) => matchesCommandRunFilter(run, filter))
|
||||
.map(copyCommandRun);
|
||||
}
|
||||
|
||||
getCommandRun(runId: string): TerminalCommandRun | undefined {
|
||||
const run = this.commandRuns.get(runId);
|
||||
return run === undefined ? undefined : copyCommandRun(run);
|
||||
}
|
||||
|
||||
cancelCommandRun(runId: string): TerminalCommandRun {
|
||||
const run = this.commandRuns.get(runId);
|
||||
if (run === undefined) throw new Error("Terminal command run not found");
|
||||
if (isTerminalCommandRunFinal(run.status)) return copyCommandRun(run);
|
||||
const terminal = this.terminals.get(run.terminalId);
|
||||
if (terminal === undefined) throw new Error("Terminal not found");
|
||||
if (!terminal.exited) terminal.pty.write("\x03");
|
||||
return copyCommandRun(run);
|
||||
}
|
||||
|
||||
get(id: string): TerminalInfo | undefined {
|
||||
@@ -106,6 +143,30 @@ export class TerminalService {
|
||||
}
|
||||
}
|
||||
|
||||
continue(id: string): TerminalInfo {
|
||||
const record = this.require(id);
|
||||
if (!record.exited) return toInfo(record);
|
||||
delete record.exitCode;
|
||||
delete record.commandRunId;
|
||||
record.exited = false;
|
||||
const marker = "\r\n[continued in interactive shell]\r\n";
|
||||
record.buffer = trimReplayBuffer(record.buffer + marker);
|
||||
record.events.emit("output", marker);
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
record.pty = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cwd: record.cwd,
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
this.attachPtyEvents(record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) return;
|
||||
@@ -120,6 +181,67 @@ export class TerminalService {
|
||||
for (const id of [...this.terminals.keys()]) this.close(id);
|
||||
}
|
||||
|
||||
private createTerminal(options: { id?: string; cwd: string; name?: string; cols?: number; rows?: number; shellArgs: string[]; commandRunId?: string }): TerminalInfo {
|
||||
if (options.cwd === "") throw new Error("cwd is required");
|
||||
const id = options.id ?? randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
const terminal = pty.spawn(shell, options.shellArgs, {
|
||||
name: "xterm-256color",
|
||||
cwd: options.cwd,
|
||||
cols: options.cols ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
const requestedName = options.name?.trim();
|
||||
const record: TerminalRecord = {
|
||||
id,
|
||||
cwd: options.cwd,
|
||||
name: requestedName !== undefined && requestedName !== "" ? requestedName : `Shell ${String(this.list(options.cwd).length + 1)}`,
|
||||
createdAt,
|
||||
exited: false,
|
||||
pty: terminal,
|
||||
buffer: "",
|
||||
events: new EventEmitter(),
|
||||
...(options.commandRunId === undefined ? {} : { commandRunId: options.commandRunId }),
|
||||
};
|
||||
this.attachPtyEvents(record);
|
||||
this.terminals.set(id, record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
}
|
||||
|
||||
private attachPtyEvents(record: TerminalRecord): void {
|
||||
record.pty.onData((data) => {
|
||||
record.buffer = trimReplayBuffer(record.buffer + data);
|
||||
record.events.emit("output", data);
|
||||
});
|
||||
record.pty.onExit(({ exitCode }) => {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
this.completeCommandRun(record.commandRunId, exitCode);
|
||||
record.events.emit("exit", exitCode);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.exited", terminal: info });
|
||||
});
|
||||
}
|
||||
|
||||
private completeCommandRun(runId: string | undefined, exitCode: number | undefined): void {
|
||||
if (runId === undefined) return;
|
||||
const run = this.commandRuns.get(runId);
|
||||
if (run === undefined || isTerminalCommandRunFinal(run.status)) return;
|
||||
const completed: TerminalCommandRun = {
|
||||
...run,
|
||||
status: exitCode === 0 ? "succeeded" : "failed",
|
||||
...(exitCode === undefined ? {} : { exitCode }),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
this.commandRuns.set(runId, completed);
|
||||
}
|
||||
|
||||
private require(id: string): TerminalRecord {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) throw new Error("Terminal not found");
|
||||
@@ -139,6 +261,7 @@ function toInfo(record: TerminalRecord): TerminalInfo {
|
||||
createdAt: record.createdAt,
|
||||
exited: record.exited,
|
||||
...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
|
||||
...(record.commandRunId === undefined ? {} : { commandRunId: record.commandRunId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,3 +269,54 @@ function trimReplayBuffer(buffer: string): string {
|
||||
if (buffer.length <= MAX_REPLAY_BUFFER) return buffer;
|
||||
return buffer.slice(buffer.length - MAX_REPLAY_BUFFER);
|
||||
}
|
||||
|
||||
function commandRunShellScript(command: string): string {
|
||||
return `printf '%s\\n' ${shellQuote(`$ ${command}`)}\n${command}`;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function validateCommandRunOptions(options: RunTerminalCommandOptions): void {
|
||||
if (options.origin.trim() === "") throw new Error("origin is required");
|
||||
if (options.projectId.trim() === "") throw new Error("projectId is required");
|
||||
if (options.workspaceId.trim() === "") throw new Error("workspaceId is required");
|
||||
if (options.cwd.trim() === "") throw new Error("cwd is required");
|
||||
if (options.title.trim() === "") throw new Error("title is required");
|
||||
if (options.command.trim() === "") throw new Error("command is required");
|
||||
parseMetadata(options.metadata);
|
||||
}
|
||||
|
||||
function parseMetadata(value: unknown): Record<string, string> {
|
||||
if (value === undefined || value === null) return {};
|
||||
if (!isRecord(value) || Array.isArray(value)) throw new Error("metadata must be an object");
|
||||
return Object.fromEntries(Object.entries(value).map(([key, metadataValue]) => {
|
||||
if (key.trim() === "") throw new Error("metadata keys must not be empty");
|
||||
if (typeof metadataValue !== "string") throw new Error("metadata values must be strings");
|
||||
return [key, metadataValue];
|
||||
}));
|
||||
}
|
||||
|
||||
function matchesCommandRunFilter(run: TerminalCommandRun, filter: TerminalCommandRunFilter): boolean {
|
||||
if (filter.projectId !== undefined && run.projectId !== filter.projectId) return false;
|
||||
if (filter.workspaceId !== undefined && run.workspaceId !== filter.workspaceId) return false;
|
||||
if (filter.terminalId !== undefined && run.terminalId !== filter.terminalId) return false;
|
||||
if (filter.statuses !== undefined && filter.statuses.length > 0 && !filter.statuses.includes(run.status)) return false;
|
||||
for (const [key, value] of Object.entries(filter.metadata ?? {})) {
|
||||
if (run.metadata[key] !== value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTerminalCommandRunFinal(status: TerminalCommandRunStatus): boolean {
|
||||
return status === "succeeded" || status === "failed";
|
||||
}
|
||||
|
||||
function copyCommandRun(run: TerminalCommandRun): TerminalCommandRun {
|
||||
return { ...run, metadata: { ...run.metadata } };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
@@ -201,6 +201,46 @@ export interface TerminalInfo {
|
||||
createdAt: string;
|
||||
exited: boolean;
|
||||
exitCode?: number;
|
||||
commandRunId?: string;
|
||||
}
|
||||
|
||||
export type TerminalCommandRunStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
export interface TerminalCommandRun {
|
||||
id: string;
|
||||
origin: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
terminalId: string;
|
||||
title: string;
|
||||
command: string;
|
||||
status: TerminalCommandRunStatus;
|
||||
exitCode?: number;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RunTerminalCommandInput {
|
||||
workspace: Workspace;
|
||||
title: string;
|
||||
command: string;
|
||||
metadata?: Record<string, string>;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalCommandRunHandle {
|
||||
run: TerminalCommandRun;
|
||||
completed: Promise<TerminalCommandRun>;
|
||||
}
|
||||
|
||||
export interface TerminalCommandRunFilter {
|
||||
projectId?: string;
|
||||
workspaceId?: string;
|
||||
terminalId?: string;
|
||||
statuses?: TerminalCommandRunStatus[];
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type PiWebServiceComponent = "web" | "sessiond";
|
||||
|
||||
Reference in New Issue
Block a user