diff --git a/.changeset/terminal-command-runs.md b/.changeset/terminal-command-runs.md new file mode 100644 index 0000000..a28e525 --- /dev/null +++ b/.changeset/terminal-command-runs.md @@ -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. diff --git a/docs/plugins.md b/docs/plugins.md index 77ec996..a40145c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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: diff --git a/plugins/actions/README.md b/plugins/actions/README.md index 5d94c3d..81e70d4 100644 --- a/plugins/actions/README.md +++ b/plugins/actions/README.md @@ -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 diff --git a/plugins/actions/src/actionRunner.test.ts b/plugins/actions/src/actionRunner.test.ts new file mode 100644 index 0000000..62658af --- /dev/null +++ b/plugins/actions/src/actionRunner.test.ts @@ -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(() => 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" }, + }); + }); +}); diff --git a/plugins/actions/src/actionRunner.ts b/plugins/actions/src/actionRunner.ts new file mode 100644 index 0000000..e5d3f1f --- /dev/null +++ b/plugins/actions/src/actionRunner.ts @@ -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 { + return terminal.runCommand({ + workspace, + title: action.title, + command: action.command, + open: true, + metadata: { + "pi.plugin": "actions", + "action.id": action.id, + }, + }); +} diff --git a/plugins/actions/src/actionsPanelElement.ts b/plugins/actions/src/actionsPanelElement.ts index d2c9e23..66565f8 100644 --- a/plugins/actions/src/actionsPanelElement.ts +++ b/plugins/actions/src/actionsPanelElement.ts @@ -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 `

No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.

${this.renderStatus()}`; return ` -

Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

+

Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

${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(); diff --git a/plugins/actions/src/pi-web-plugin.ts b/plugins/actions/src/pi-web-plugin.ts index 6d6fb6c..22a5874 100644 --- a/plugins/actions/src/pi-web-plugin.ts +++ b/plugins/actions/src/pi-web-plugin.ts @@ -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``, + render: (context) => html``, }, ], }, diff --git a/plugins/actions/src/piWebInternal.ts b/plugins/actions/src/piWebInternal.ts new file mode 100644 index 0000000..6bc09ec --- /dev/null +++ b/plugins/actions/src/piWebInternal.ts @@ -0,0 +1,62 @@ +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; + +export interface InternalRunTerminalCommandInput { + workspace: Workspace; + title: string; + command: string; + metadata?: Record; + 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; +} + +export interface InternalTerminalCommandRunHandle { + run: InternalTerminalCommandRun; + completed: Promise; +} + +export interface InternalTerminalCommandRunsRuntime { + runCommand(input: InternalRunTerminalCommandInput): Promise; + 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 { + return typeof value === "object" && value !== null; +} diff --git a/plugins/actions/src/terminalDispatcher.test.ts b/plugins/actions/src/terminalDispatcher.test.ts deleted file mode 100644 index 030ac18..0000000 --- a/plugins/actions/src/terminalDispatcher.test.ts +++ /dev/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" }); - }); -}); diff --git a/plugins/actions/src/terminalDispatcher.ts b/plugins/actions/src/terminalDispatcher.ts deleted file mode 100644 index 9607d6e..0000000 --- a/plugins/actions/src/terminalDispatcher.ts +++ /dev/null @@ -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; - -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 { - 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 { - 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) => { - 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 { - 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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 67ae508..701108a 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 4dcd48a..0026218 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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 { + 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 { + 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)), }; diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index c8c9db6..0988d07 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -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" }] }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 72db003..7b466dc 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { 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 { + 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 { diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 9a25dd5..79c7058 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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; workspaceActivities: Record; workspacesByProjectId: Record; + workspaceDeletionRuns: Record; commandDialog: Extract | 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, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index e994b7c..564f86c 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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(); + private readonly terminalCommandRunRuntimes = new Map(); 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 { @@ -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 { + 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); }} > 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 { + 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 { + 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 { + 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 { + 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, workspaceId: string): Record { + return Object.fromEntries(Object.entries(runs).filter(([candidate]) => candidate !== workspaceId)); +} + function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); } diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 4e1bbb0..941a093 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -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): 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 { + 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 { + 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 { + 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` +
+
+ ${run.title} +

Command is running. Press Ctrl+C or use the button to cancel.

+ ${run.command} +
+ +
+ `; + } + if (terminal?.exited === true) { + const continuing = this.continuingTerminalIds.includes(terminal.id); + return html` +
+
+ ${commandRunCompletionLabel(run)} +

Output is preserved. Continue in a shell to inspect or run follow-up commands.

+ ${run.command} +
+ +
+ `; + } + return null; + } + override render() { return html`
@@ -314,6 +435,7 @@ export class TerminalPanel extends LitElement { ${this.error === undefined ? null : html`

${this.error}

`} + ${this.renderCommandRunNotice()} ${this.loading ? html`

Loading terminals…

` : null}
@@ -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" }; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index f54ca06..3874756 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -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 = {}; + @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 { ${this.renderActivity(workspace)} ${label} + ${this.isDeleting(workspace) ? html`Deleting…` : null} ${items.length === 0 ? null : html` @@ -110,6 +113,7 @@ export class WorkspaceList extends LitElement { >⋯ ${open ? html`
{ event.stopPropagation(); }}> + ${this.renderWorkspaceActions(workspace)} ${this.renderWorkspaceDetails(label, items, workspace)}
` : 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` +
+ +
+ `; + } + private renderWorkspaceDetails(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { return html`
@@ -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, "-")}`; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 9481127..41e06be 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 405c824..e06064c 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -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; +} + export class WorkspaceController { + private readonly api: Pick; + constructor( private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, - private readonly sessions: SessionController, + private readonly sessions: Pick, 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 { + 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 { + 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]; +} + diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 3d51d5d..e8fd181 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -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); +} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index f58f64d..03508de 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -10,6 +10,14 @@ function createContext(statePatch: Partial = {}) { 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 = {}) { 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 { + return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch }; } function testThemeTokens(): ThemeTokens { diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index a22c1b4..78f6a86 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -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>(); +const workspacePanelScopes = new WeakMap WorkspacePanelContext>(); type RegisteredPluginAction = Omit & { 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; +} diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index e7e93e6..a139359 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -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; + listCommandRuns(filter?: TerminalCommandRunFilter): Promise; + getCommandRun(runId: string): Promise; + open(options?: { terminalId?: string | undefined }): void; +} + export interface PluginRuntimeContext { state: AppState; + piWebInternal?: PiWebInternalRuntimeContext; openActionPalette: () => void; focusPrompt: () => void; addProject: () => void | Promise; @@ -52,6 +64,7 @@ export interface PluginRuntimeContext { refreshGit: () => void | Promise; refreshAppData: () => void | Promise; reloadPage: () => void; + deleteWorkspace: (workspace?: Workspace) => void | Promise; startSession: () => void | Promise; archiveSession: () => void | Promise; stopActiveWork: () => void | Promise; @@ -80,6 +93,7 @@ export interface WorkspacePanelVisibilityContext { export interface WorkspacePanelContext { workspace: Workspace; state: AppState; + piWebInternal?: PiWebInternalRuntimeContext; fileTree: FileTreeEntry[]; expandedDirs: Record; selectedFilePath: string | undefined; diff --git a/src/client/src/runtime/terminalRuntime.test.ts b/src/client/src/runtime/terminalRuntime.test.ts new file mode 100644 index 0000000..70c1c40 --- /dev/null +++ b/src/client/src/runtime/terminalRuntime.test.ts @@ -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"); + }); +}); diff --git a/src/client/src/runtime/terminalRuntime.ts b/src/client/src/runtime/terminalRuntime.ts new file mode 100644 index 0000000..e02e92c --- /dev/null +++ b/src/client/src/runtime/terminalRuntime.ts @@ -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; +type SetTimer = (handler: () => void, timeout: number) => TimerId; +type ClearTimer = (id: TimerId) => void; + +export interface TerminalCommandRunsRuntimeDependencies { + api?: Pick; + openTerminal: (workspace: Workspace | undefined, options?: { terminalId?: string | undefined }) => void | Promise; + 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, + pollIntervalMs: number, + setTimer: SetTimer, + clearTimer: ClearTimer, +): Promise { + 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); }; +} diff --git a/src/client/src/workspaceDeletion.test.ts b/src/client/src/workspaceDeletion.test.ts new file mode 100644 index 0000000..5d449a0 --- /dev/null +++ b/src/client/src/workspaceDeletion.test.ts @@ -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"]); + }); +}); diff --git a/src/client/src/workspaceDeletion.ts b/src/client/src/workspaceDeletion.ts new file mode 100644 index 0000000..8437993 --- /dev/null +++ b/src/client/src/workspaceDeletion.ts @@ -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 { + return { + [workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation, + [targetWorkspaceIdMetadataKey]: workspace.id, + [targetWorkspacePathMetadataKey]: workspace.path, + }; +} + +export function workspaceDeletionRunFilter(projectId?: string): { projectId?: string; metadata: Record } { + return { + ...(projectId === undefined ? {} : { projectId }), + metadata: { [workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation }, + }; +} + +export function latestWorkspaceDeletionRuns(runs: TerminalCommandRun[]): Record { + const latest: Record = {}; + 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[] { + return Object.entries(runsByWorkspaceId) + .filter(([, run]) => isWorkspaceDeletionRunPending(run)) + .map(([workspaceId]) => workspaceId); +} + +export function isWorkspaceDeletionPending(state: Pick, 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; +} diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts index bb620ac..d5e2dee 100644 --- a/src/server/terminalProxyRoutes.ts +++ b/src/server/terminalProxyRoutes.ts @@ -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; +} + +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 { const upstream = await daemon.request(method, path, body); reply.code(upstream.statusCode); diff --git a/src/server/terminals/terminalRoutes.test.ts b/src/server/terminals/terminalRoutes.test.ts index 847f756..eb2e4d5 100644 --- a/src/server/terminals/terminalRoutes.test.ts +++ b/src/server/terminals/terminalRoutes.test.ts @@ -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()).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()).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(); 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 { + 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 { diff --git a/src/server/terminals/terminalRoutes.ts b/src/server/terminals/terminalRoutes.ts index 2d20809..2f4e77d 100644 --- a/src/server/terminals/terminalRoutes.ts +++ b/src/server/terminals/terminalRoutes.ts @@ -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 { + 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"); diff --git a/src/server/terminals/terminalService.test.ts b/src/server/terminals/terminalService.test.ts new file mode 100644 index 0000000..759afab --- /dev/null +++ b/src/server/terminals/terminalService.test.ts @@ -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 { + 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 { + 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))); + } + }); +} diff --git a/src/server/terminals/terminalService.ts b/src/server/terminals/terminalService.ts index 141322c..1a8093a 100644 --- a/src/server/terminals/terminalService.ts +++ b/src/server/terminals/terminalService.ts @@ -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(); + private readonly commandRuns = new Map(); constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick) {} @@ -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 { + 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 { + return typeof value === "object" && value !== null; +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 266f61b..08fcb54 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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; +} + +export interface RunTerminalCommandInput { + workspace: Workspace; + title: string; + command: string; + metadata?: Record; + open?: boolean; +} + +export interface TerminalCommandRunHandle { + run: TerminalCommandRun; + completed: Promise; +} + +export interface TerminalCommandRunFilter { + projectId?: string; + workspaceId?: string; + terminalId?: string; + statuses?: TerminalCommandRunStatus[]; + metadata?: Record; } export type PiWebServiceComponent = "web" | "sessiond";