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 ( + + + + Delete Workspace + + {isLoading ? ( + Checking workspace status... + ) : !canDelete ? ( + + Cannot delete workspace: {reason} + + ) : ( + <> + Are you sure you want to delete "{workspaceName}"? + {warning && ( + + Warning: {warning} + + )} + + This will remove the workspace and its associated git + worktree. This action cannot be undone. + + + )} + + + + Cancel + { + e.preventDefault(); + handleDelete(); + }} + disabled={!canDelete || isDeleting || isLoading} + className="bg-destructive text-white hover:bg-destructive/90" + > + {isDeleting ? "Deleting..." : "Delete"} + + + + + ); +} 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 bb50eb77cd8..d4fb91f9e74 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,12 +1,13 @@ import { Button } from "@superset/ui/button"; import { cn } from "@superset/ui/utils"; +import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; import { - useDeleteWorkspace, useReorderWorkspaces, useSetActiveWorkspace, } from "renderer/react-query/workspaces"; +import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; const WORKSPACE_TYPE = "WORKSPACE"; @@ -32,8 +33,8 @@ export function WorkspaceItem({ onMouseLeave, }: WorkspaceItemProps) { const setActive = useSetActiveWorkspace(); - const deleteWorkspace = useDeleteWorkspace(); const reorderWorkspaces = useReorderWorkspaces(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [{ isDragging }, drag] = useDrag( () => ({ @@ -62,51 +63,60 @@ export function WorkspaceItem({ }); return ( -
- {/* Main workspace button */} - + {/* Main workspace button */} + - -
+ + + + + ); } diff --git a/bun.lock b/bun.lock index c7974cdd73f..61eac705fce 100644 --- a/bun.lock +++ b/bun.lock @@ -360,6 +360,7 @@ "name": "@superset/ui", "version": "0.0.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -368,7 +369,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -863,6 +864,8 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -3839,6 +3842,8 @@ "@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index c02a1404edf..0cae180d790 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "exports": { + "./alert-dialog": "./src/components/alert-dialog.tsx", "./button": "./src/components/button.tsx", "./input": "./src/components/input.tsx", "./card": "./src/components/card.tsx", @@ -29,6 +30,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -37,7 +39,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/ui/src/alert-dialog.tsx b/packages/ui/src/alert-dialog.tsx new file mode 100644 index 00000000000..e4509e9d976 --- /dev/null +++ b/packages/ui/src/alert-dialog.tsx @@ -0,0 +1 @@ +export * from "./components/alert-dialog"; diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx new file mode 100644 index 00000000000..9d776e860bf --- /dev/null +++ b/packages/ui/src/components/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "../lib/utils" +import { buttonVariants } from "./button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}