diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a4e04eb31ca..9c651a50add 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@superset/desktop", "productName": "Superset", "description": "The last developer tool you'll ever need", - "version": "0.0.8", + "version": "0.0.9", "main": "./dist/main/index.js", "resources": "src/resources", "repository": { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 8c251172c6b..a91fa878a35 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -364,3 +364,54 @@ export async function checkNeedsRebase( ]); return Number.parseInt(behindCount.trim(), 10) > 0; } + +/** + * Checks if a worktree has uncommitted changes (staged, unstaged, or untracked files) + * @param worktreePath - Path to the worktree + * @returns true if there are any uncommitted changes + */ +export async function hasUncommittedChanges( + worktreePath: string, +): Promise { + const git = simpleGit(worktreePath); + const status = await git.status(); + return !status.isClean(); +} + +/** + * Checks if a worktree has commits that haven't been pushed to the remote + * @param worktreePath - Path to the worktree + * @returns true if there are unpushed commits, false if all commits are pushed or no upstream exists + */ +export async function hasUnpushedCommits( + worktreePath: string, +): Promise { + const git = simpleGit(worktreePath); + try { + // Count commits that are on HEAD but not on the upstream tracking branch + // @{upstream} refers to the configured upstream branch (e.g., origin/branch-name) + const aheadCount = await git.raw([ + "rev-list", + "--count", + "@{upstream}..HEAD", + ]); + return Number.parseInt(aheadCount.trim(), 10) > 0; + } catch { + // No upstream configured or other error - check if any commits exist at all + // that aren't on origin (for branches without tracking) + try { + // If there's no upstream, check if branch has commits not on any remote + const localCommits = await git.raw([ + "rev-list", + "--count", + "HEAD", + "--not", + "--remotes", + ]); + return Number.parseInt(localCommits.trim(), 10) > 0; + } catch { + // If all else fails, assume no unpushed commits + return false; + } + } +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts index 7fffca69fb9..d9f640d6811 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts @@ -69,9 +69,25 @@ mock.module("./utils/git", () => ({ import { createWorkspacesRouter } from "./workspaces"; // Helper to mock simple-git with specific worktree list output -function mockSimpleGitWithWorktreeList(worktreeListOutput: string) { +function mockSimpleGitWithWorktreeList( + worktreeListOutput: string, + options?: { isClean?: boolean; unpushedCommitCount?: number }, +) { + const isClean = options?.isClean ?? true; + const unpushedCommitCount = options?.unpushedCommitCount ?? 0; const mockGit = { - raw: mock(() => Promise.resolve(worktreeListOutput)), + raw: mock((args: string[]) => { + // Handle worktree list + if (args[0] === "worktree" && args[1] === "list") { + return Promise.resolve(worktreeListOutput); + } + // Handle rev-list for unpushed commits check + if (args[0] === "rev-list" && args[1] === "--count") { + return Promise.resolve(String(unpushedCommitCount)); + } + return Promise.resolve(""); + }), + status: mock(() => Promise.resolve({ isClean: () => isClean })), }; mock.module("simple-git", () => ({ default: mock(() => mockGit), @@ -82,6 +98,7 @@ function mockSimpleGitWithWorktreeList(worktreeListOutput: string) { function mockSimpleGitWithError(error: Error) { const mockGit = { raw: mock(() => Promise.reject(error)), + status: mock(() => Promise.resolve({ isClean: () => true })), }; mock.module("simple-git", () => ({ default: mock(() => mockGit), @@ -207,4 +224,113 @@ describe("workspaces router - canDelete", () => { "--porcelain", ]); }); + + it("returns hasChanges: false when worktree is clean", async () => { + mockSimpleGitWithWorktreeList( + "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", + { isClean: true }, + ); + + const router = createWorkspacesRouter(); + const caller = router.createCaller({}); + const result = await caller.canDelete({ id: "workspace-1" }); + + expect(result.canDelete).toBe(true); + expect(result.hasChanges).toBe(false); + }); + + it("returns hasChanges: true when worktree has uncommitted changes", async () => { + mockSimpleGitWithWorktreeList( + "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", + { isClean: false }, + ); + + const router = createWorkspacesRouter(); + const caller = router.createCaller({}); + const result = await caller.canDelete({ id: "workspace-1" }); + + expect(result.canDelete).toBe(true); + expect(result.hasChanges).toBe(true); + }); + + it("returns hasChanges: false when worktree not found in git", async () => { + mockSimpleGitWithWorktreeList( + "worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch", + { isClean: false }, + ); + + const router = createWorkspacesRouter(); + const caller = router.createCaller({}); + const result = await caller.canDelete({ id: "workspace-1" }); + + expect(result.canDelete).toBe(true); + expect(result.warning).toContain("not found in git"); + // hasChanges should be false when worktree doesn't exist + expect(result.hasChanges).toBe(false); + }); + + it("returns hasUnpushedCommits: false when all commits are pushed", async () => { + mockSimpleGitWithWorktreeList( + "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", + { isClean: true, unpushedCommitCount: 0 }, + ); + + const router = createWorkspacesRouter(); + const caller = router.createCaller({}); + const result = await caller.canDelete({ id: "workspace-1" }); + + expect(result.canDelete).toBe(true); + expect(result.hasUnpushedCommits).toBe(false); + }); + + it("returns hasUnpushedCommits: true when there are unpushed commits", async () => { + mockSimpleGitWithWorktreeList( + "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", + { isClean: true, unpushedCommitCount: 3 }, + ); + + const router = createWorkspacesRouter(); + const caller = router.createCaller({}); + const result = await caller.canDelete({ id: "workspace-1" }); + + expect(result.canDelete).toBe(true); + expect(result.hasUnpushedCommits).toBe(true); + }); + + it("returns hasUnpushedCommits: false when worktree not found in git", async () => { + mockSimpleGitWithWorktreeList( + "worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch", + { isClean: true, unpushedCommitCount: 5 }, + ); + + const router = createWorkspacesRouter(); + const caller = router.createCaller({}); + const result = await caller.canDelete({ id: "workspace-1" }); + + expect(result.canDelete).toBe(true); + expect(result.warning).toContain("not found in git"); + // hasUnpushedCommits should be false when worktree doesn't exist + expect(result.hasUnpushedCommits).toBe(false); + }); + + it("skips git checks when skipGitChecks is true", async () => { + const mockGit = mockSimpleGitWithWorktreeList( + "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", + { isClean: false, unpushedCommitCount: 5 }, + ); + + const router = createWorkspacesRouter(); + const caller = router.createCaller({}); + const result = await caller.canDelete({ + id: "workspace-1", + skipGitChecks: true, + }); + + expect(result.canDelete).toBe(true); + // When skipping git checks, these should be false (defaults) + expect(result.hasChanges).toBe(false); + expect(result.hasUnpushedCommits).toBe(false); + // git.status should not have been called + expect(mockGit.status).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index c3786a3d76f..a7c4b801c9e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -13,6 +13,8 @@ import { generateBranchName, getDefaultBranch, hasOriginRemote, + hasUncommittedChanges, + hasUnpushedCommits, removeWorktree, worktreeExists, } from "./utils/git"; @@ -288,7 +290,13 @@ export const createWorkspacesRouter = () => { }), canDelete: publicProcedure - .input(z.object({ id: z.string() })) + .input( + z.object({ + id: z.string(), + // Skip expensive git checks (status, unpushed) during polling - only check terminal count + skipGitChecks: z.boolean().optional(), + }), + ) .query(async ({ input }) => { const workspace = db.data.workspaces.find((w) => w.id === input.id); @@ -298,12 +306,28 @@ export const createWorkspacesRouter = () => { reason: "Workspace not found", workspace: null, activeTerminalCount: 0, + hasChanges: false, + hasUnpushedCommits: false, }; } const activeTerminalCount = terminalManager.getSessionCountByWorkspaceId(input.id); + // If skipping git checks, return early with just terminal count + // This is used during polling to avoid expensive git operations + if (input.skipGitChecks) { + return { + canDelete: true, + reason: null, + workspace, + warning: null, + activeTerminalCount, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + const worktree = db.data.worktrees.find( (wt) => wt.id === workspace.worktreeId, ); @@ -326,15 +350,25 @@ export const createWorkspacesRouter = () => { warning: "Worktree not found in git (may have been manually removed)", activeTerminalCount, + hasChanges: false, + hasUnpushedCommits: false, }; } + // Check for uncommitted changes and unpushed commits in parallel + const [hasChanges, unpushedCommits] = await Promise.all([ + hasUncommittedChanges(worktree.path), + hasUnpushedCommits(worktree.path), + ]); + return { canDelete: true, reason: null, workspace, warning: null, activeTerminalCount, + hasChanges, + hasUnpushedCommits: unpushedCommits, }; } catch (error) { return { @@ -342,6 +376,8 @@ export const createWorkspacesRouter = () => { reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`, workspace, activeTerminalCount, + hasChanges: false, + hasUnpushedCommits: false, }; } } @@ -352,6 +388,8 @@ export const createWorkspacesRouter = () => { workspace, warning: "No associated worktree found", activeTerminalCount, + hasChanges: false, + hasUnpushedCommits: false, }; }), 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 3bf66c9af32..79d19c65acc 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 @@ -27,16 +27,36 @@ export function DeleteWorkspaceDialog({ }: DeleteWorkspaceDialogProps) { 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 }, + // Initial query for git status (expensive) - only runs once when dialog opens + const { data: gitStatusData, isLoading: isLoadingGitStatus } = + trpc.workspaces.canDelete.useQuery( + { id: workspaceId }, + { + enabled: open, + staleTime: Number.POSITIVE_INFINITY, // Don't refetch automatically + }, + ); + + // Polling query for terminal count only (cheap) - skips git checks + const { data: terminalCountData } = trpc.workspaces.canDelete.useQuery( + { id: workspaceId, skipGitChecks: true }, { enabled: open, refetchInterval: open ? 2000 : false, }, ); + // Merge the data: use git status from initial query, terminal count from polling + const canDeleteData = gitStatusData + ? { + ...gitStatusData, + activeTerminalCount: + terminalCountData?.activeTerminalCount ?? + gitStatusData.activeTerminalCount, + } + : terminalCountData; + const isLoading = isLoadingGitStatus; + const handleDelete = () => { onOpenChange(false); @@ -70,6 +90,8 @@ export function DeleteWorkspaceDialog({ const reason = canDeleteData?.reason; const warning = canDeleteData?.warning; const activeTerminalCount = canDeleteData?.activeTerminalCount ?? 0; + const hasChanges = canDeleteData?.hasChanges ?? false; + const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false; return ( @@ -91,6 +113,16 @@ export function DeleteWorkspaceDialog({ Warning: {warning} )} + {hasChanges && ( + + This workspace has uncommitted changes that will be lost. + + )} + {hasUnpushedCommits && ( + + This workspace has unpushed commits that will be lost. + + )} {activeTerminalCount > 0 && ( {activeTerminalCount} active terminal 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 bc5908df427..66486b170f0 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,11 +1,14 @@ import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; import { + useDeleteWorkspace, useReorderWorkspaces, useSetActiveWorkspace, } from "renderer/react-query/workspaces"; @@ -42,12 +45,54 @@ export function WorkspaceItem({ }: WorkspaceItemProps) { const setActive = useSetActiveWorkspace(); const reorderWorkspaces = useReorderWorkspaces(); + const deleteWorkspace = useDeleteWorkspace(); const closeSettings = useCloseSettings(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const windows = useWindowsStore((s) => s.windows); const panes = useWindowsStore((s) => s.panes); const rename = useWorkspaceRename(id, title); + // Query to check if workspace is empty - only enabled when needed + const canDeleteQuery = trpc.workspaces.canDelete.useQuery( + { id }, + { enabled: false }, + ); + + const handleDeleteClick = async () => { + // Prevent double-clicks and race conditions + if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; + + try { + // Always fetch fresh data before deciding + const { data: canDeleteData } = await canDeleteQuery.refetch(); + + const isEmpty = + canDeleteData?.canDelete && + canDeleteData.activeTerminalCount === 0 && + !canDeleteData.warning && + !canDeleteData.hasChanges && + !canDeleteData.hasUnpushedCommits; + + if (isEmpty) { + // Delete directly without confirmation + toast.promise(deleteWorkspace.mutateAsync({ id }), { + loading: `Deleting "${title}"...`, + success: `Workspace "${title}" deleted`, + error: (error) => + error instanceof Error + ? `Failed to delete workspace: ${error.message}` + : "Failed to delete workspace", + }); + } else { + // Show confirmation dialog + setShowDeleteDialog(true); + } + } catch { + // On error checking status, show dialog for user to decide + setShowDeleteDialog(true); + } + }; + // Check if any pane in windows belonging to this workspace needs attention const workspaceWindows = windows.filter((w) => w.workspaceId === id); const workspacePaneIds = new Set( @@ -182,7 +227,7 @@ export function WorkspaceItem({ size="icon" onClick={(e) => { e.stopPropagation(); - setShowDeleteDialog(true); + handleDeleteClick(); }} className={cn( "mt-1 absolute right-1 top-1/2 -translate-y-1/2 cursor-pointer size-5 group-hover:opacity-100", diff --git a/bun.lock b/bun.lock index 8367b2a3267..1c64316cc34 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.3", + "version": "0.0.9", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0",