diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts
new file mode 100644
index 00000000000..7baaa28e759
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it, mock } from "bun:test";
+import { createWorkspacesRouter } from "./workspaces";
+import * as gitUtils from "./utils/git";
+
+// Mock the git utilities
+mock.module("./utils/git", () => ({
+ createWorktree: mock(() => Promise.resolve()),
+ removeWorktree: mock(() => Promise.resolve()),
+ generateBranchName: mock(() => "test-branch-123"),
+}));
+
+// Mock the database
+const mockDb = {
+ data: {
+ workspaces: [
+ {
+ id: "workspace-1",
+ projectId: "project-1",
+ worktreeId: "worktree-1",
+ name: "Test Workspace",
+ tabOrder: 0,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ lastOpenedAt: Date.now(),
+ },
+ ],
+ worktrees: [
+ {
+ id: "worktree-1",
+ projectId: "project-1",
+ path: "/path/to/worktree",
+ branch: "test-branch",
+ createdAt: Date.now(),
+ },
+ ],
+ projects: [
+ {
+ id: "project-1",
+ name: "Test Project",
+ mainRepoPath: "/path/to/repo",
+ color: "#ff0000",
+ tabOrder: 0,
+ createdAt: Date.now(),
+ lastOpenedAt: Date.now(),
+ },
+ ],
+ settings: {
+ lastActiveWorkspaceId: "workspace-1",
+ },
+ },
+ update: mock(async (fn: (data: typeof mockDb.data) => void) => {
+ fn(mockDb.data);
+ }),
+};
+
+describe("workspaces router - delete", () => {
+ it("should successfully delete workspace and remove worktree", async () => {
+ const router = createWorkspacesRouter();
+
+ // Mock removeWorktree to succeed
+ const removeWorktreeMock = mock(() => Promise.resolve());
+ mock.module("./utils/git", () => ({
+ ...gitUtils,
+ removeWorktree: removeWorktreeMock,
+ }));
+
+ const caller = router.createCaller({ db: mockDb as any });
+
+ const result = await caller.delete({ id: "workspace-1" });
+
+ expect(result.success).toBe(true);
+ expect(removeWorktreeMock).toHaveBeenCalledWith(
+ "/path/to/repo",
+ "/path/to/worktree",
+ );
+ expect(mockDb.data.workspaces).toHaveLength(0);
+ expect(mockDb.data.worktrees).toHaveLength(0);
+ });
+
+ it("should fail deletion if worktree removal fails", async () => {
+ // Reset mock data
+ mockDb.data.workspaces = [
+ {
+ id: "workspace-1",
+ projectId: "project-1",
+ worktreeId: "worktree-1",
+ name: "Test Workspace",
+ tabOrder: 0,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ lastOpenedAt: Date.now(),
+ },
+ ];
+ mockDb.data.worktrees = [
+ {
+ id: "worktree-1",
+ projectId: "project-1",
+ path: "/path/to/worktree",
+ branch: "test-branch",
+ createdAt: Date.now(),
+ },
+ ];
+
+ const router = createWorkspacesRouter();
+
+ // Mock removeWorktree to fail
+ const removeWorktreeMock = mock(() =>
+ Promise.reject(new Error("Failed to remove worktree")),
+ );
+ mock.module("./utils/git", () => ({
+ ...gitUtils,
+ removeWorktree: removeWorktreeMock,
+ }));
+
+ const caller = router.createCaller({ db: mockDb as any });
+
+ const result = await caller.delete({ id: "workspace-1" });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Failed to remove worktree");
+ // Workspace should NOT be removed from DB if worktree removal fails
+ expect(mockDb.data.workspaces).toHaveLength(1);
+ expect(mockDb.data.worktrees).toHaveLength(1);
+ });
+});
+
+describe("workspaces router - canDelete", () => {
+ it("should return true when worktree can be deleted", async () => {
+ const router = createWorkspacesRouter();
+
+ // Mock git to return worktree list
+ const mockGit = {
+ raw: mock(() =>
+ Promise.resolve("/path/to/worktree\n/path/to/other-worktree"),
+ ),
+ };
+ const mockSimpleGit = mock(() => mockGit);
+ mock.module("simple-git", () => ({
+ default: mockSimpleGit,
+ }));
+
+ const caller = router.createCaller({ db: mockDb as any });
+
+ const result = await caller.canDelete({ id: "workspace-1" });
+
+ expect(result.canDelete).toBe(true);
+ expect(result.reason).toBeNull();
+ expect(result.warning).toBeNull();
+ });
+
+ it("should return warning when worktree doesn't exist in git", async () => {
+ const router = createWorkspacesRouter();
+
+ // Mock git to return worktree list without our worktree
+ const mockGit = {
+ raw: mock(() => Promise.resolve("/path/to/other-worktree")),
+ };
+ const mockSimpleGit = mock(() => mockGit);
+ mock.module("simple-git", () => ({
+ default: mockSimpleGit,
+ }));
+
+ const caller = router.createCaller({ db: mockDb as any });
+
+ const result = await caller.canDelete({ id: "workspace-1" });
+
+ expect(result.canDelete).toBe(true);
+ expect(result.warning).toContain("not found in git");
+ });
+
+ it("should return false when git check fails", async () => {
+ const router = createWorkspacesRouter();
+
+ // Mock git to throw error
+ const mockGit = {
+ raw: mock(() => Promise.reject(new Error("Git error"))),
+ };
+ const mockSimpleGit = mock(() => mockGit);
+ mock.module("simple-git", () => ({
+ default: mockSimpleGit,
+ }));
+
+ const caller = router.createCaller({ db: mockDb as any });
+
+ const result = await caller.canDelete({ id: "workspace-1" });
+
+ expect(result.canDelete).toBe(false);
+ expect(result.reason).toContain("Failed to check worktree status");
+ });
+});
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
index 873084249ce..fc306cf08fc 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
@@ -196,6 +196,72 @@ export const createWorkspacesRouter = () => {
return { success: true };
}),
+ canDelete: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .query(async ({ input }) => {
+ const workspace = db.data.workspaces.find((w) => w.id === input.id);
+
+ if (!workspace) {
+ return {
+ canDelete: false,
+ reason: "Workspace not found",
+ workspace: null,
+ };
+ }
+
+ const worktree = db.data.worktrees.find(
+ (wt) => wt.id === workspace.worktreeId,
+ );
+ const project = db.data.projects.find(
+ (p) => p.id === workspace.projectId,
+ );
+
+ // Check if worktree can be removed
+ if (worktree && project) {
+ try {
+ // Dry-run: verify the worktree exists in git
+ const git = await import("simple-git").then((m) => m.default);
+ const gitInstance = git(project.mainRepoPath);
+ const worktrees = await gitInstance.raw(["worktree", "list"]);
+
+ // Check if our worktree path is in the list
+ const worktreeExists = worktrees.includes(worktree.path);
+
+ if (!worktreeExists) {
+ // Worktree doesn't exist in git, but we can still delete the workspace
+ return {
+ canDelete: true,
+ reason: null,
+ workspace,
+ warning:
+ "Worktree not found in git (may have been manually removed)",
+ };
+ }
+
+ return {
+ canDelete: true,
+ reason: null,
+ workspace,
+ warning: null,
+ };
+ } catch (error) {
+ return {
+ canDelete: false,
+ reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`,
+ workspace,
+ };
+ }
+ }
+
+ // No worktree to remove, can safely delete
+ return {
+ canDelete: true,
+ reason: null,
+ workspace,
+ warning: "No associated worktree found",
+ };
+ }),
+
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
@@ -212,14 +278,23 @@ export const createWorkspacesRouter = () => {
(p) => p.id === workspace.projectId,
);
+ // Always attempt to remove the worktree first
if (worktree && project) {
try {
await removeWorktree(project.mainRepoPath, worktree.path);
} catch (error) {
- console.error("Failed to remove worktree:", error);
+ // If worktree removal fails, return error and don't proceed with DB cleanup
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ console.error("Failed to remove worktree:", errorMessage);
+ return {
+ success: false,
+ error: `Failed to remove worktree: ${errorMessage}`,
+ };
}
}
+ // Only proceed with DB cleanup if worktree was successfully removed (or doesn't exist)
await db.update((data) => {
data.workspaces = data.workspaces.filter((w) => w.id !== input.id);
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
new file mode 100644
index 00000000000..bbe640cebb0
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx
@@ -0,0 +1,97 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@superset/ui/alert-dialog";
+import { useState } from "react";
+import { trpc } from "renderer/lib/trpc";
+import { useDeleteWorkspace } from "renderer/react-query/workspaces";
+
+interface DeleteWorkspaceDialogProps {
+ workspaceId: string;
+ workspaceName: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function DeleteWorkspaceDialog({
+ workspaceId,
+ workspaceName,
+ open,
+ onOpenChange,
+}: DeleteWorkspaceDialogProps) {
+ const [isDeleting, setIsDeleting] = useState(false);
+ const deleteWorkspace = useDeleteWorkspace();
+
+ // Query to check if workspace can be deleted
+ const { data: canDeleteData, isLoading } = trpc.workspaces.canDelete.useQuery(
+ { id: workspaceId },
+ { enabled: open }, // Only run when dialog is open
+ );
+
+ const handleDelete = async () => {
+ setIsDeleting(true);
+ try {
+ await deleteWorkspace.mutateAsync({ id: workspaceId });
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Failed to delete workspace:", error);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const canDelete = canDeleteData?.canDelete ?? true;
+ const reason = canDeleteData?.reason;
+ const warning = canDeleteData?.warning;
+
+ return (
+