diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index f21d1b71507..e526dbfa2c8 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { db } from "main/lib/db"; +import { terminalManager } from "main/lib/terminal-manager"; import { nanoid } from "nanoid"; import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants"; import { z } from "zod"; @@ -283,9 +284,13 @@ export const createWorkspacesRouter = () => { canDelete: false, reason: "Workspace not found", workspace: null, + activeTerminalCount: 0, }; } + const activeTerminalCount = + terminalManager.getSessionCountByWorkspaceId(input.id); + const worktree = db.data.worktrees.find( (wt) => wt.id === workspace.worktreeId, ); @@ -307,6 +312,7 @@ export const createWorkspacesRouter = () => { workspace, warning: "Worktree not found in git (may have been manually removed)", + activeTerminalCount, }; } @@ -315,12 +321,14 @@ export const createWorkspacesRouter = () => { reason: null, workspace, warning: null, + activeTerminalCount, }; } catch (error) { return { canDelete: false, reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`, workspace, + activeTerminalCount, }; } } @@ -330,6 +338,7 @@ export const createWorkspacesRouter = () => { reason: null, workspace, warning: "No associated worktree found", + activeTerminalCount, }; }), @@ -342,6 +351,11 @@ export const createWorkspacesRouter = () => { return { success: false, error: "Workspace not found" }; } + // Kill all terminal processes in this workspace first + const terminalResult = await terminalManager.killByWorkspaceId( + input.id, + ); + const worktree = db.data.worktrees.find( (wt) => wt.id === workspace.worktreeId, ); @@ -418,7 +432,12 @@ export const createWorkspacesRouter = () => { } }); - return { success: true, teardownError }; + const terminalWarning = + terminalResult.failed > 0 + ? `${terminalResult.failed} terminal process(es) may still be running` + : undefined; + + return { success: true, teardownError, terminalWarning }; }), setActive: publicProcedure diff --git a/apps/desktop/src/main/lib/terminal-manager.test.ts b/apps/desktop/src/main/lib/terminal-manager.test.ts index c46210d352c..b46396a66ce 100644 --- a/apps/desktop/src/main/lib/terminal-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal-manager.test.ts @@ -580,6 +580,160 @@ describe("TerminalManager", () => { }); }); + describe("killByWorkspaceId", () => { + it("should kill session for a workspace and return count", async () => { + await manager.createOrAttach({ + tabId: "tab-kill-single", + workspaceId: "workspace-kill-single", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + const result = await manager.killByWorkspaceId("workspace-kill-single"); + + // With the mock, the session exits cleanly via the kill mock's setImmediate + expect(result.killed + result.failed).toBe(1); + }); + + it("should not kill sessions from other workspaces", async () => { + await manager.createOrAttach({ + tabId: "tab-other", + workspaceId: "workspace-other", + tabTitle: "Test Tab", + workspaceName: "Other Workspace", + }); + + await manager.killByWorkspaceId("workspace-different"); + + // Session should still exist + expect(manager.getSession("tab-other")).not.toBeNull(); + expect(manager.getSession("tab-other")?.isAlive).toBe(true); + }); + + it("should return zero counts for non-existent workspace", async () => { + const result = await manager.killByWorkspaceId("non-existent"); + + expect(result.killed).toBe(0); + expect(result.failed).toBe(0); + }); + + it("should delete history for killed sessions", async () => { + await manager.createOrAttach({ + tabId: "tab-kill-history", + workspaceId: "workspace-kill", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + // Trigger some data to create history + const onDataCallback = + mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; + if (onDataCallback) { + onDataCallback("test output\n"); + } + + await manager.killByWorkspaceId("workspace-kill"); + + // Wait a bit for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify history directory was deleted + const historyDir = join( + testTmpDir, + ".superset/terminal-history/workspace-kill/tab-kill-history", + ); + const exists = await fs + .stat(historyDir) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); + + it("should clean up already-dead sessions", async () => { + await manager.createOrAttach({ + tabId: "tab-dead", + workspaceId: "workspace-dead", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + // Simulate the session dying naturally + const onExitCallback = + mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; + if (onExitCallback) { + await onExitCallback({ exitCode: 0, signal: undefined }); + } + + // Wait for the dead session to be kept in map (5s timeout in onExit) + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await manager.killByWorkspaceId("workspace-dead"); + + expect(result.killed).toBe(1); + expect(result.failed).toBe(0); + }); + }); + + describe("getSessionCountByWorkspaceId", () => { + it("should return count of active sessions for workspace", async () => { + await manager.createOrAttach({ + tabId: "tab-1", + workspaceId: "workspace-count", + tabTitle: "Test Tab 1", + workspaceName: "Test Workspace", + }); + + await manager.createOrAttach({ + tabId: "tab-2", + workspaceId: "workspace-count", + tabTitle: "Test Tab 2", + workspaceName: "Test Workspace", + }); + + await manager.createOrAttach({ + tabId: "tab-3", + workspaceId: "other-workspace", + tabTitle: "Test Tab 3", + workspaceName: "Other Workspace", + }); + + expect(manager.getSessionCountByWorkspaceId("workspace-count")).toBe(2); + expect(manager.getSessionCountByWorkspaceId("other-workspace")).toBe(1); + }); + + it("should return zero for non-existent workspace", () => { + expect(manager.getSessionCountByWorkspaceId("non-existent")).toBe(0); + }); + + it("should not count dead sessions", async () => { + await manager.createOrAttach({ + tabId: "tab-alive", + workspaceId: "workspace-mixed", + tabTitle: "Test Tab Alive", + workspaceName: "Test Workspace", + }); + + await manager.createOrAttach({ + tabId: "tab-dead", + workspaceId: "workspace-mixed", + tabTitle: "Test Tab Dead", + workspaceName: "Test Workspace", + }); + + // Simulate the second session dying + const onExitCallback = + mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; + if (onExitCallback) { + await onExitCallback({ exitCode: 0, signal: undefined }); + } + + // Wait for state to update + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(manager.getSessionCountByWorkspaceId("workspace-mixed")).toBe(1); + }); + }); + describe("multi-session history persistence", () => { it("should persist history across multiple sessions", async () => { // Session 1: Create and write data diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index b7047199ff5..ada4b65a4a5 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -297,6 +297,111 @@ export class TerminalManager extends EventEmitter { }; } + async killByWorkspaceId( + workspaceId: string, + ): Promise<{ killed: number; failed: number }> { + const sessionsToKill = Array.from(this.sessions.entries()).filter( + ([, session]) => session.workspaceId === workspaceId, + ); + + if (sessionsToKill.length === 0) { + return { killed: 0, failed: 0 }; + } + + const results: Promise[] = []; + + for (const [tabId, session] of sessionsToKill) { + if (session.isAlive) { + session.deleteHistoryOnExit = true; + + const killPromise = new Promise((resolve) => { + let resolved = false; + let sigtermTimeout: ReturnType | undefined; + let sigkillTimeout: ReturnType | undefined; + + const cleanup = (success: boolean) => { + if (resolved) return; + resolved = true; + this.off(`exit:${tabId}`, exitHandler); + if (sigtermTimeout) clearTimeout(sigtermTimeout); + if (sigkillTimeout) clearTimeout(sigkillTimeout); + resolve(success); + }; + + const exitHandler = () => cleanup(true); + this.once(`exit:${tabId}`, exitHandler); + + // First timeout: SIGTERM didn't work, try SIGKILL + sigtermTimeout = setTimeout(() => { + if (resolved || !session.isAlive) return; + + try { + session.pty.kill("SIGKILL"); + } catch (error) { + console.error( + `Failed to send SIGKILL to terminal ${tabId}:`, + error, + ); + } + + // Second timeout: SIGKILL didn't work either + sigkillTimeout = setTimeout(() => { + if (resolved) return; + + if (session.isAlive) { + console.error( + `Terminal ${tabId} did not exit after SIGKILL, forcing cleanup. Process may still be running.`, + ); + // Clean up session state before resolving + session.isAlive = false; + this.sessions.delete(tabId); + this.closeHistory(session).catch(() => {}); + } + cleanup(false); + }, 500); + sigkillTimeout.unref(); + }, 2000); + sigtermTimeout.unref(); + + // Send initial SIGTERM + try { + session.pty.kill(); + } catch (error) { + console.error( + `Failed to send SIGTERM to terminal ${tabId}:`, + error, + ); + // Mark as failed immediately since we can't even signal it + session.isAlive = false; + this.sessions.delete(tabId); + this.closeHistory(session).catch(() => {}); + cleanup(false); + } + }); + + results.push(killPromise); + } else { + // Clean up history for already-dead sessions + session.deleteHistoryOnExit = true; + await this.closeHistory(session); + this.sessions.delete(tabId); + results.push(Promise.resolve(true)); + } + } + + const outcomes = await Promise.all(results); + const killed = outcomes.filter(Boolean).length; + const failed = outcomes.length - killed; + + return { killed, failed }; + } + + getSessionCountByWorkspaceId(workspaceId: string): number { + return Array.from(this.sessions.values()).filter( + (session) => session.workspaceId === workspaceId && session.isAlive, + ).length; + } + async cleanup(): Promise { const exitPromises: Promise[] = []; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx index 851ad61c2de..3bf66c9af32 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx @@ -28,9 +28,13 @@ export function DeleteWorkspaceDialog({ const deleteWorkspace = useDeleteWorkspace(); // Query to check if workspace can be deleted + // Refetch every 2 seconds while dialog is open to keep terminal count fresh const { data: canDeleteData, isLoading } = trpc.workspaces.canDelete.useQuery( { id: workspaceId }, - { enabled: open }, // Only run when dialog is open + { + enabled: open, + refetchInterval: open ? 2000 : false, + }, ); const handleDelete = () => { @@ -39,11 +43,18 @@ export function DeleteWorkspaceDialog({ toast.promise(deleteWorkspace.mutateAsync({ id: workspaceId }), { loading: `Deleting "${workspaceName}"...`, success: (result) => { - if (result.teardownError) { + if (result.teardownError || result.terminalWarning) { setTimeout(() => { - toast.warning("Workspace deleted with teardown warning", { - description: result.teardownError, - }); + if (result.teardownError) { + toast.warning("Workspace deleted with teardown warning", { + description: result.teardownError, + }); + } + if (result.terminalWarning) { + toast.warning("Workspace deleted with terminal warning", { + description: result.terminalWarning, + }); + } }, 100); } return `Workspace "${workspaceName}" deleted`; @@ -58,6 +69,7 @@ export function DeleteWorkspaceDialog({ const canDelete = canDeleteData?.canDelete ?? true; const reason = canDeleteData?.reason; const warning = canDeleteData?.warning; + const activeTerminalCount = canDeleteData?.activeTerminalCount ?? 0; return ( @@ -79,6 +91,12 @@ export function DeleteWorkspaceDialog({ Warning: {warning} )} + {activeTerminalCount > 0 && ( + + {activeTerminalCount} active terminal + {activeTerminalCount === 1 ? "" : "s"} will be terminated. + + )} This will remove the workspace and its associated git worktree. This action cannot be undone. diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index 3bbc7512ea0..9c442580a80 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -1,5 +1,6 @@ import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; @@ -172,22 +173,29 @@ export function WorkspaceItem({ )} - + + + + + + Delete workspace + +