Archived
fix: close workspace terminals before deletion
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { terminalsApi } from "./clients";
|
||||
import { terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -30,6 +30,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("machine-scoped terminal command-run API", () => {
|
||||
it("deletes workspaces through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch(commandRun);
|
||||
|
||||
await workspacesApi.deleteWorkspace("p 1", "w/1", "remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("creates command runs through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch(commandRun);
|
||||
|
||||
@@ -42,6 +53,17 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
|
||||
});
|
||||
|
||||
it("closes all workspace terminals through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch({ closed: true });
|
||||
|
||||
await terminalsApi.closeWorkspaceTerminals("p 1", "w/1", "remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("lists, reads, and cancels command runs through the selected machine scope", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse([commandRun]),
|
||||
|
||||
@@ -72,6 +72,7 @@ export const projectsApi = {
|
||||
|
||||
export const workspacesApi = {
|
||||
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
|
||||
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||
};
|
||||
@@ -116,6 +117,7 @@ export const sessionsApi = {
|
||||
export const terminalsApi = {
|
||||
terminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
|
||||
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
||||
closeWorkspaceTerminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseClosed, { method: "DELETE" }),
|
||||
closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
||||
continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
||||
runTerminalCommand: (origin: string, input: RunTerminalCommandInput, machineId = "local") => request(`${machinePrefix(machineId)}/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 ?? {} }) }),
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(projectsApi.closeProject("p 1", machineId)),
|
||||
ignoreParseFailure(projectsApi.projectDirectories("/r", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)),
|
||||
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
|
||||
@@ -67,6 +68,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.cancelOAuthFlow("flow 1", machineId)),
|
||||
ignoreParseFailure(terminalsApi.terminals("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(terminalsApi.startTerminal("p 1", "w 1", { cols: 120, rows: 40 }, machineId)),
|
||||
ignoreParseFailure(terminalsApi.closeWorkspaceTerminals("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(terminalsApi.closeTerminal("p 1", "w 1", "t 1", machineId)),
|
||||
ignoreParseFailure(terminalsApi.continueTerminal("p 1", "w 1", "t 1", machineId)),
|
||||
ignoreParseFailure(terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test" }, machineId)),
|
||||
|
||||
@@ -33,7 +33,7 @@ import { readRoute, writeRoute, type AppRoute } from "../route";
|
||||
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
|
||||
import { applyShortcutPreferences } from "../shortcutPreferences";
|
||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import "./MachineList";
|
||||
import "./ProjectList";
|
||||
@@ -1007,32 +1007,21 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
const machineId = selectedMachineId(this.state);
|
||||
try {
|
||||
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
|
||||
if (mainWorkspace === undefined) {
|
||||
this.setState({ error: "Project main workspace not found" });
|
||||
return;
|
||||
}
|
||||
const run = await workspacesApi.deleteWorkspace(workspace.projectId, workspace.id, machineId);
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
const handle = await this.terminalCommandRunsForOrigin("core", machineId).runCommand({
|
||||
workspace: mainWorkspace,
|
||||
title: `Delete workspace: ${label}`,
|
||||
command: `git worktree remove ${shellQuote(workspace.path)}`,
|
||||
open: true,
|
||||
metadata: workspaceDeletionMetadata(workspace),
|
||||
});
|
||||
this.recordWorkspaceDeletionRun(handle.run, machineId);
|
||||
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run, machineId)).catch((error: unknown) => {
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
|
||||
});
|
||||
this.recordWorkspaceDeletionRun(run, machineId);
|
||||
const commandWorkspace = await this.workspaceForCommandRun(run);
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
if (commandWorkspace !== undefined) void this.openRuntimeTerminal(machineId, commandWorkspace, { terminalId: run.terminalId });
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) 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 async workspaceForCommandRun(run: TerminalCommandRun): Promise<Workspace | undefined> {
|
||||
let workspaces = this.state.selectedProject?.id === run.projectId ? this.state.workspaces : this.state.workspacesByProjectId[run.projectId];
|
||||
if (workspaces === undefined || workspaces.length === 0) workspaces = await this.workspaces.refreshProjectWorkspaces(run.projectId);
|
||||
return workspaces.find((workspace) => workspace.id === run.workspaceId);
|
||||
}
|
||||
|
||||
private recordWorkspaceDeletionRun(run: TerminalCommandRun, machineId: string): void {
|
||||
@@ -1413,10 +1402,6 @@ function machineScopedKey(machineId: string, value: string): string {
|
||||
return JSON.stringify([machineId, value]);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { workspaceDeleteOperation, workspaceDeleteOperationMetadataKey, targetWorkspaceIdMetadataKey, targetWorkspacePathMetadataKey } from "../../shared/workspaceDeletion";
|
||||
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 { targetWorkspaceIdMetadataKey, targetWorkspacePathMetadataKey, workspaceDeleteOperation, workspaceDeleteOperationMetadataKey, workspaceDeletionMetadata } from "../../shared/workspaceDeletion";
|
||||
|
||||
export function workspaceDeletionRunFilter(projectId?: string): { projectId?: string; metadata: Record<string, string> } {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user