fix: close workspace terminals before deletion

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 21:31:39 +02:00
parent 30fb9602d6
commit 9c3dafc4d4
17 changed files with 415 additions and 38 deletions
+23 -1
View File
@@ -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]),
+2
View File
@@ -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)),
+10 -25
View File
@@ -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);
}
+2 -12
View File
@@ -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 {