From 9638a4accdfb653f84f0147d342ca90cd3c88fce Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Fri, 5 Dec 2025 16:44:36 -0800 Subject: [PATCH 1/5] no confirmation for empty workspace --- .../lib/trpc/routers/workspaces/utils/git.ts | 13 ++++++++ .../lib/trpc/routers/workspaces/workspaces.ts | 9 ++++++ .../WorkspaceTabs/DeleteWorkspaceDialog.tsx | 6 ++++ .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 32 ++++++++++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) 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 2ff186dd9f6..b427c4f04f9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -351,3 +351,16 @@ 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(); +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index e526dbfa2c8..eabb5541116 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -12,6 +12,7 @@ import { fetchDefaultBranch, generateBranchName, getDefaultBranch, + hasUncommittedChanges, removeWorktree, worktreeExists, } from "./utils/git"; @@ -285,6 +286,7 @@ export const createWorkspacesRouter = () => { reason: "Workspace not found", workspace: null, activeTerminalCount: 0, + hasChanges: false, }; } @@ -313,15 +315,20 @@ export const createWorkspacesRouter = () => { warning: "Worktree not found in git (may have been manually removed)", activeTerminalCount, + hasChanges: false, }; } + // Check for uncommitted changes + const hasChanges = await hasUncommittedChanges(worktree.path); + return { canDelete: true, reason: null, workspace, warning: null, activeTerminalCount, + hasChanges, }; } catch (error) { return { @@ -329,6 +336,7 @@ export const createWorkspacesRouter = () => { reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`, workspace, activeTerminalCount, + hasChanges: false, }; } } @@ -339,6 +347,7 @@ export const createWorkspacesRouter = () => { workspace, warning: "No associated worktree found", activeTerminalCount, + hasChanges: 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..05f9122e36b 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 @@ -70,6 +70,7 @@ export function DeleteWorkspaceDialog({ const reason = canDeleteData?.reason; const warning = canDeleteData?.warning; const activeTerminalCount = canDeleteData?.activeTerminalCount ?? 0; + const hasChanges = canDeleteData?.hasChanges ?? false; return ( @@ -91,6 +92,11 @@ export function DeleteWorkspaceDialog({ Warning: {warning} )} + {hasChanges && ( + + This workspace has uncommitted changes 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 9c442580a80..c63fe9fd8b3 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,39 @@ 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 (no active terminals) + const { data: canDeleteData } = trpc.workspaces.canDelete.useQuery({ id }); + + const handleDeleteClick = () => { + const isEmpty = + canDeleteData?.canDelete && + canDeleteData.activeTerminalCount === 0 && + !canDeleteData.warning && + !canDeleteData.hasChanges; + + 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); + } + }; + // Check if any pane in windows belonging to this workspace needs attention const workspaceWindows = windows.filter((w) => w.workspaceId === id); const workspacePaneIds = new Set( @@ -181,7 +211,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", From fe773f81ffd1753233b3749a4eeca01fa857a655 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Sun, 7 Dec 2025 20:45:21 -0800 Subject: [PATCH 2/5] more --- .../lib/trpc/routers/workspaces/workspaces.test.ts | 3 +++ .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 12 +++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) 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..66e6ef2cb1b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts @@ -72,6 +72,8 @@ import { createWorkspacesRouter } from "./workspaces"; function mockSimpleGitWithWorktreeList(worktreeListOutput: string) { const mockGit = { raw: mock(() => Promise.resolve(worktreeListOutput)), + // Mock clean status (no uncommitted changes) + status: mock(() => Promise.resolve({ isClean: () => true })), }; mock.module("simple-git", () => ({ default: mock(() => mockGit), @@ -82,6 +84,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), 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 c63fe9fd8b3..cbaa21930ea 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 @@ -52,10 +52,16 @@ export function WorkspaceItem({ const panes = useWindowsStore((s) => s.panes); const rename = useWorkspaceRename(id, title); - // Query to check if workspace is empty (no active terminals) - const { data: canDeleteData } = trpc.workspaces.canDelete.useQuery({ id }); + // Query to check if workspace is empty - only enabled when needed + const canDeleteQuery = trpc.workspaces.canDelete.useQuery( + { id }, + { enabled: false }, + ); + + const handleDeleteClick = async () => { + // Always fetch fresh data before deciding + const { data: canDeleteData } = await canDeleteQuery.refetch(); - const handleDeleteClick = () => { const isEmpty = canDeleteData?.canDelete && canDeleteData.activeTerminalCount === 0 && From 7491550ad422092a945129bf853595d108e4df87 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Sun, 7 Dec 2025 20:58:43 -0800 Subject: [PATCH 3/5] more fixes --- .../routers/workspaces/workspaces.test.ts | 53 +++++++++++++++++-- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 46 +++++++++------- bun.lock | 2 +- 3 files changed, 78 insertions(+), 23 deletions(-) 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 66e6ef2cb1b..7dd9b349d72 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts @@ -69,11 +69,14 @@ 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 }, +) { + const isClean = options?.isClean ?? true; const mockGit = { raw: mock(() => Promise.resolve(worktreeListOutput)), - // Mock clean status (no uncommitted changes) - status: mock(() => Promise.resolve({ isClean: () => true })), + status: mock(() => Promise.resolve({ isClean: () => isClean })), }; mock.module("simple-git", () => ({ default: mock(() => mockGit), @@ -210,4 +213,48 @@ 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); + }); }); 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 bbc105aea45..d6f0595d551 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 @@ -59,27 +59,35 @@ export function WorkspaceItem({ ); const handleDeleteClick = async () => { - // Always fetch fresh data before deciding - const { data: canDeleteData } = await canDeleteQuery.refetch(); + // Prevent double-clicks and race conditions + if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; - const isEmpty = - canDeleteData?.canDelete && - canDeleteData.activeTerminalCount === 0 && - !canDeleteData.warning && - !canDeleteData.hasChanges; + try { + // Always fetch fresh data before deciding + const { data: canDeleteData } = await canDeleteQuery.refetch(); - 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 + const isEmpty = + canDeleteData?.canDelete && + canDeleteData.activeTerminalCount === 0 && + !canDeleteData.warning && + !canDeleteData.hasChanges; + + 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); } }; diff --git a/bun.lock b/bun.lock index 8367b2a3267..2ebcd107600 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.3", + "version": "0.0.8", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", From 9e12c005e665a4fdc6cab61eefd518982dc7996d Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Sun, 7 Dec 2025 21:03:42 -0800 Subject: [PATCH 4/5] update superset version --- apps/desktop/package.json | 2 +- bun.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/bun.lock b/bun.lock index 2ebcd107600..1c64316cc34 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", From bd3a3341c0ae7bfddae87d08c028ceb963eddcfc Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Sun, 7 Dec 2025 21:25:54 -0800 Subject: [PATCH 5/5] performance polling fix, and uncommitted changes foot gun --- .../lib/trpc/routers/workspaces/utils/git.ts | 38 +++++++++ .../routers/workspaces/workspaces.test.ts | 80 ++++++++++++++++++- .../lib/trpc/routers/workspaces/workspaces.ts | 35 +++++++- .../WorkspaceTabs/DeleteWorkspaceDialog.tsx | 34 +++++++- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 3 +- 5 files changed, 180 insertions(+), 10 deletions(-) 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 569a0d8435c..a91fa878a35 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -377,3 +377,41 @@ export async function hasUncommittedChanges( 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 7dd9b349d72..d9f640d6811 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts @@ -71,11 +71,22 @@ import { createWorkspacesRouter } from "./workspaces"; // Helper to mock simple-git with specific worktree list output function mockSimpleGitWithWorktreeList( worktreeListOutput: string, - options?: { isClean?: boolean }, + 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", () => ({ @@ -257,4 +268,69 @@ describe("workspaces router - canDelete", () => { // 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 40d2b85435c..a7c4b801c9e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -14,6 +14,7 @@ import { getDefaultBranch, hasOriginRemote, hasUncommittedChanges, + hasUnpushedCommits, removeWorktree, worktreeExists, } from "./utils/git"; @@ -289,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); @@ -300,12 +307,27 @@ export const createWorkspacesRouter = () => { 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, ); @@ -329,11 +351,15 @@ export const createWorkspacesRouter = () => { "Worktree not found in git (may have been manually removed)", activeTerminalCount, hasChanges: false, + hasUnpushedCommits: false, }; } - // Check for uncommitted changes - const hasChanges = await hasUncommittedChanges(worktree.path); + // Check for uncommitted changes and unpushed commits in parallel + const [hasChanges, unpushedCommits] = await Promise.all([ + hasUncommittedChanges(worktree.path), + hasUnpushedCommits(worktree.path), + ]); return { canDelete: true, @@ -342,6 +368,7 @@ export const createWorkspacesRouter = () => { warning: null, activeTerminalCount, hasChanges, + hasUnpushedCommits: unpushedCommits, }; } catch (error) { return { @@ -350,6 +377,7 @@ export const createWorkspacesRouter = () => { workspace, activeTerminalCount, hasChanges: false, + hasUnpushedCommits: false, }; } } @@ -361,6 +389,7 @@ export const createWorkspacesRouter = () => { 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 05f9122e36b..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); @@ -71,6 +91,7 @@ export function DeleteWorkspaceDialog({ const warning = canDeleteData?.warning; const activeTerminalCount = canDeleteData?.activeTerminalCount ?? 0; const hasChanges = canDeleteData?.hasChanges ?? false; + const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false; return ( @@ -97,6 +118,11 @@ export function DeleteWorkspaceDialog({ 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 d6f0595d551..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 @@ -70,7 +70,8 @@ export function WorkspaceItem({ canDeleteData?.canDelete && canDeleteData.activeTerminalCount === 0 && !canDeleteData.warning && - !canDeleteData.hasChanges; + !canDeleteData.hasChanges && + !canDeleteData.hasUnpushedCommits; if (isEmpty) { // Delete directly without confirmation