From 80e6c916bfb34337b06dfb35980a07c0ebd73d71 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Sun, 11 Jan 2026 21:35:35 -0800 Subject: [PATCH 1/2] allow deleting on workspaces page --- .../routers/workspaces/procedures/delete.ts | 147 ++++++++++++++++++ .../WorkspaceRow/DeleteWorktreeDialog.tsx | 125 +++++++++++++++ .../WorkspaceRow/WorkspaceRow.tsx | 63 +++++++- 3 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 93d51c2c53a..2b9e754ae60 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -294,5 +294,152 @@ export const createDeleteProcedures = () => { return { success: true, terminalWarning }; }), + + // Check if a closed worktree (no active workspace) can be deleted + canDeleteWorktree: publicProcedure + .input( + z.object({ + worktreeId: z.string(), + skipGitChecks: z.boolean().optional(), + }), + ) + .query(async ({ input }) => { + const worktree = getWorktree(input.worktreeId); + + if (!worktree) { + return { + canDelete: false, + reason: "Worktree not found", + worktree: null, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + + const project = getProject(worktree.projectId); + + if (!project) { + return { + canDelete: false, + reason: "Project not found", + worktree, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + + if (input.skipGitChecks) { + return { + canDelete: true, + reason: null, + worktree, + warning: null, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + + try { + const exists = await worktreeExists(project.mainRepoPath, worktree.path); + + if (!exists) { + return { + canDelete: true, + reason: null, + worktree, + warning: "Worktree not found in git (may have been manually removed)", + hasChanges: false, + hasUnpushedCommits: false, + }; + } + + const [hasChanges, unpushedCommits] = await Promise.all([ + hasUncommittedChanges(worktree.path), + hasUnpushedCommits(worktree.path), + ]); + + return { + canDelete: true, + reason: null, + worktree, + warning: null, + hasChanges, + hasUnpushedCommits: unpushedCommits, + }; + } catch (error) { + return { + canDelete: false, + reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`, + worktree, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + }), + + // Delete a closed worktree (no active workspace) by worktree ID + deleteWorktree: publicProcedure + .input(z.object({ worktreeId: z.string() })) + .mutation(async ({ input }) => { + const worktree = getWorktree(input.worktreeId); + + if (!worktree) { + return { success: false, error: "Worktree not found" }; + } + + const project = getProject(worktree.projectId); + + if (!project) { + return { success: false, error: "Project not found" }; + } + + // Acquire project lock to prevent racing with concurrent operations + await workspaceInitManager.acquireProjectLock(project.id); + + try { + const exists = await worktreeExists(project.mainRepoPath, worktree.path); + + if (exists) { + const teardownResult = await runTeardown( + project.mainRepoPath, + worktree.path, + worktree.branch, + ); + if (!teardownResult.success) { + console.error( + `Teardown failed for worktree ${worktree.branch}:`, + teardownResult.error, + ); + } + } + + try { + if (exists) { + await removeWorktree(project.mainRepoPath, worktree.path); + } else { + console.warn( + `Worktree ${worktree.path} not found in git, skipping removal`, + ); + } + } catch (error) { + 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}`, + }; + } + } finally { + workspaceInitManager.releaseProjectLock(project.id); + } + + deleteWorktreeRecord(input.worktreeId); + hideProjectIfNoWorkspaces(worktree.projectId); + + track("worktree_deleted", { worktree_id: input.worktreeId }); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx new file mode 100644 index 00000000000..8774c9e93d2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx @@ -0,0 +1,125 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { trpc } from "renderer/lib/trpc"; + +interface DeleteWorktreeDialogProps { + worktreeId: string; + worktreeName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteWorktreeDialog({ + worktreeId, + worktreeName, + open, + onOpenChange, +}: DeleteWorktreeDialogProps) { + const utils = trpc.useUtils(); + + const deleteWorktree = trpc.workspaces.deleteWorktree.useMutation({ + onSuccess: () => { + utils.workspaces.getWorktreesByProject.invalidate(); + }, + }); + + const { data: canDeleteData, isLoading } = + trpc.workspaces.canDeleteWorktree.useQuery( + { worktreeId }, + { + enabled: open, + staleTime: Number.POSITIVE_INFINITY, + }, + ); + + const handleDelete = () => { + onOpenChange(false); + + toast.promise(deleteWorktree.mutateAsync({ worktreeId }), { + loading: `Deleting "${worktreeName}"...`, + success: `Deleted "${worktreeName}"`, + error: (error) => + error instanceof Error ? error.message : "Failed to delete", + }); + }; + + const canDelete = canDeleteData?.canDelete ?? true; + const reason = canDeleteData?.reason; + const hasChanges = canDeleteData?.hasChanges ?? false; + const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false; + const hasWarnings = hasChanges || hasUnpushedCommits; + + return ( + + + + + Delete worktree "{worktreeName}"? + + +
+ {isLoading ? ( + "Checking status..." + ) : !canDelete ? ( + {reason} + ) : ( + + This will permanently delete the worktree and its files from + disk. + + )} +
+
+
+ + {!isLoading && canDelete && hasWarnings && ( +
+
+ {hasChanges && hasUnpushedCommits + ? "Has uncommitted changes and unpushed commits" + : hasChanges + ? "Has uncommitted changes" + : "Has unpushed commits"} +
+
+ )} + + + + + + + + + Permanently delete worktree from disk. + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx index c179b18238e..864b1c1a42d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx @@ -1,3 +1,9 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useState } from "react"; @@ -8,7 +14,10 @@ import { LuRotateCw, } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; +import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import { STROKE_WIDTH } from "../../WorkspaceSidebar/constants"; +import { DeleteWorkspaceDialog } from "../../WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"; +import { DeleteWorktreeDialog } from "./DeleteWorktreeDialog"; import type { WorkspaceItem } from "../types"; import { getRelativeTime } from "../utils"; @@ -31,6 +40,8 @@ export function WorkspaceRow({ }: WorkspaceRowProps) { const isBranch = workspace.type === "branch"; const [hasHovered, setHasHovered] = useState(false); + const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = + useWorkspaceDeleteHandler(); // Lazy-load GitHub status on hover to avoid N+1 queries const { data: githubStatus } = trpc.workspaces.getGitHubStatus.useQuery( @@ -57,7 +68,7 @@ export function WorkspaceRow({ } }; - return ( + const button = ( ); + + // Determine the delete/close action label based on workspace type and state + const isOpenWorkspace = workspace.workspaceId !== null; + const isClosedWorktree = !isOpenWorkspace && workspace.worktreeId !== null; + const actionLabel = isBranch + ? "Close workspace" + : isClosedWorktree + ? "Delete worktree" + : "Delete workspace"; + + // Can delete open workspaces or closed worktrees + const canDelete = isOpenWorkspace || isClosedWorktree; + + return ( + <> + + {button} + + handleDeleteClick()} + className="text-destructive focus:text-destructive" + disabled={!canDelete} + > + {actionLabel} + + + + + {/* Dialog for open workspaces */} + {workspace.workspaceId && ( + + )} + + {/* Dialog for closed worktrees */} + {isClosedWorktree && workspace.worktreeId && ( + + )} + + ); } From c3cfcb755ce2c34a74b5d562d5450d217ee3f33f Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Sun, 11 Jan 2026 22:19:10 -0800 Subject: [PATCH 2/2] fix lint and duplication and other bad ish --- .../routers/workspaces/procedures/delete.ts | 13 +++++++--- .../renderer/react-query/workspaces/index.ts | 1 + .../workspaces/useDeleteWorktree.ts | 26 +++++++++++++++++++ .../WorkspaceRow/DeleteWorktreeDialog.tsx | 9 ++----- .../WorkspaceRow/WorkspaceRow.tsx | 2 +- 5 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/renderer/react-query/workspaces/useDeleteWorktree.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 2b9e754ae60..30069ad27ec 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -340,14 +340,18 @@ export const createDeleteProcedures = () => { } try { - const exists = await worktreeExists(project.mainRepoPath, worktree.path); + const exists = await worktreeExists( + project.mainRepoPath, + worktree.path, + ); if (!exists) { return { canDelete: true, reason: null, worktree, - warning: "Worktree not found in git (may have been manually removed)", + warning: + "Worktree not found in git (may have been manually removed)", hasChanges: false, hasUnpushedCommits: false, }; @@ -397,7 +401,10 @@ export const createDeleteProcedures = () => { await workspaceInitManager.acquireProjectLock(project.id); try { - const exists = await worktreeExists(project.mainRepoPath, worktree.path); + const exists = await worktreeExists( + project.mainRepoPath, + worktree.path, + ); if (exists) { const teardownResult = await runTeardown( diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 438ba849573..b109fc6d1cb 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -2,6 +2,7 @@ export { useCloseWorkspace } from "./useCloseWorkspace"; export { useCreateBranchWorkspace } from "./useCreateBranchWorkspace"; export { useCreateWorkspace } from "./useCreateWorkspace"; export { useDeleteWorkspace } from "./useDeleteWorkspace"; +export { useDeleteWorktree } from "./useDeleteWorktree"; export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorktree.ts new file mode 100644 index 00000000000..fd48f5ec362 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorktree.ts @@ -0,0 +1,26 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for deleting a closed worktree (one without an active workspace). + * Handles cache invalidation for worktree-related queries. + */ +export function useDeleteWorktree( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.deleteWorktree.useMutation({ + ...options, + onSettled: async (...args) => { + // Invalidate worktree queries to refresh the list + await utils.workspaces.getWorktreesByProject.invalidate(); + await options?.onSettled?.(...args); + }, + onSuccess: async (...args) => { + await options?.onSuccess?.(...args); + }, + onError: async (...args) => { + await options?.onError?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx index 8774c9e93d2..e25b2beef46 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/DeleteWorktreeDialog.tsx @@ -10,6 +10,7 @@ import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { trpc } from "renderer/lib/trpc"; +import { useDeleteWorktree } from "renderer/react-query/workspaces/useDeleteWorktree"; interface DeleteWorktreeDialogProps { worktreeId: string; @@ -24,13 +25,7 @@ export function DeleteWorktreeDialog({ open, onOpenChange, }: DeleteWorktreeDialogProps) { - const utils = trpc.useUtils(); - - const deleteWorktree = trpc.workspaces.deleteWorktree.useMutation({ - onSuccess: () => { - utils.workspaces.getWorktreesByProject.invalidate(); - }, - }); + const deleteWorktree = useDeleteWorktree(); const { data: canDeleteData, isLoading } = trpc.workspaces.canDeleteWorktree.useQuery( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx index 864b1c1a42d..a3728c5b08a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx @@ -17,9 +17,9 @@ import { trpc } from "renderer/lib/trpc"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import { STROKE_WIDTH } from "../../WorkspaceSidebar/constants"; import { DeleteWorkspaceDialog } from "../../WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"; -import { DeleteWorktreeDialog } from "./DeleteWorktreeDialog"; import type { WorkspaceItem } from "../types"; import { getRelativeTime } from "../utils"; +import { DeleteWorktreeDialog } from "./DeleteWorktreeDialog"; const GITHUB_STATUS_STALE_TIME = 5 * 60 * 1000; // 5 minutes