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