diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 2fe12371e..e09257ea0 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -16,6 +16,9 @@ const mocks = vi.hoisted(() => { updateSettings: vi.fn(), openSettingsTarget: vi.fn(), openSettingsPage: vi.fn(), + settings: null, + gitStatusByWorktree: {} as Record, + setGitStatus: vi.fn(), deleteStateByWorktreeId: {} } return { state, buttonProps: [] as Record[] } @@ -54,6 +57,12 @@ vi.mock('@/components/ui/scroll-area', () => ({ ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
})) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => {children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children} +})) + vi.mock('sonner', () => ({ toast: { error: vi.fn(), @@ -114,6 +123,7 @@ describe('DeleteWorktreeDialog lineage copy', () => { mocks.state.allWorktrees.mockReturnValue([]) mocks.state.repos = [] mocks.state.worktreeLineageById = {} + mocks.state.gitStatusByWorktree = {} mocks.state.deleteStateByWorktreeId = {} mocks.buttonProps = [] vi.mocked(runWorktreeDeletesInParallel).mockResolvedValue([]) @@ -136,7 +146,7 @@ describe('DeleteWorktreeDialog lineage copy', () => { expect(markup).toContain('Child workspace') expect(markup).toContain('from git and delete their workspace folders.') expect(markup).not.toContain('from git and delete its workspace folder.') - expect(markup).toContain('Delete All 2') + expect(markup).toContain('Delete 2 Workspaces') expect(markup).not.toContain('Delete Parent Only') expect(markup).not.toContain('Don't ask again') @@ -145,13 +155,14 @@ describe('DeleteWorktreeDialog lineage copy', () => { buttonText(props).includes('Delete Parent Only') ) - expect(destructiveButton ? buttonText(destructiveButton) : '').toContain('Delete All 2') + expect(destructiveButton ? buttonText(destructiveButton) : '').toContain('Delete 2 Workspaces') expect(parentOnlyButton).toBeUndefined() const deleteAllButton = destructiveButton as { onClick?: () => void } | undefined deleteAllButton?.onClick?.() expect(runWorktreeDeletesInParallel).toHaveBeenCalledWith([child, parent], { + force: true, onForceDeleted: expect.any(Function) }) }) @@ -163,7 +174,12 @@ describe('DeleteWorktreeDialog lineage copy', () => { ) const { DeleteWorktreeLineageNotice } = await import('./DeleteWorktreeLineageNotice') - const markup = renderToStaticMarkup() + const markup = renderToStaticMarkup( + + ) expect(markup).toContain('min-w-0 max-w-full overflow-hidden rounded-md') expect(markup).toContain('mt-2 min-w-0 max-w-full space-y-1 overflow-hidden') @@ -193,7 +209,26 @@ describe('DeleteWorktreeDialog lineage copy', () => { const markup = renderToStaticMarkup() expect(markup).toContain('from Orca. The project folder on disk will not be deleted.') - expect(markup).not.toContain('from git and delete its workspace folder.') + expect(markup).not.toContain('including uncommitted or untracked files') + }) + + it('shows an inline warning when the workspace has uncommitted or untracked changes', async () => { + const workspace = makeWorktree('Feature workspace', '/workspaces/feature') + mocks.state.modalData = { worktreeId: workspace.id } + mocks.state.allWorktrees.mockReturnValue([workspace]) + mocks.state.gitStatusByWorktree = { + [workspace.id]: [ + { path: 'src/file.ts', status: 'modified' }, + { path: 'notes.md', status: 'modified' } + ] + } + + const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') + const markup = renderToStaticMarkup() + + expect(markup).toContain('2 uncommitted or untracked changes') + expect(markup).toContain('Deleting this workspace permanently removes these changes from disk.') + expect(markup).not.toContain('Also delete local branch') }) it('notifies the dialog caller after a toast force delete succeeds', async () => { @@ -212,6 +247,7 @@ describe('DeleteWorktreeDialog lineage copy', () => { deleteButton?.onClick?.(undefined as never) expect(runWorktreeDeletesInParallel).toHaveBeenCalledWith([workspace], { + force: true, onForceDeleted: expect.any(Function) }) const options = vi.mocked(runWorktreeDeletesInParallel).mock.calls[0]?.[1] as diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 797bf6a59..5eb82e8eb 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -7,14 +7,17 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { ScrollArea } from '@/components/ui/scroll-area' -import { AlertTriangle, Check, LoaderCircle, Trash2 } from 'lucide-react' import { useAppStore } from '@/store' import { toast } from 'sonner' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { DeleteWorktreeLineageNotice } from './DeleteWorktreeLineageNotice' +import { DeleteWorktreeSkipConfirmOption } from './DeleteWorktreeSkipConfirmOption' +import { DeleteWorktreeDialogFooter } from './DeleteWorktreeDialogFooter' +import { DeleteWorktreeTargetPreview } from './DeleteWorktreeTargetPreview' +import { DeleteWorktreeWarningPanels } from './DeleteWorktreeWarningPanels' import { countFolderWorkspaceDeletes, getDeleteWorktreeDialogCopy, @@ -34,6 +37,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const updateSettings = useAppStore((s) => s.updateSettings) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const settings = useAppStore((s) => s.settings) + const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) + const setGitStatus = useAppStore((s) => s.setGitStatus) const isOpen = activeModal === 'delete-worktree' const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : '' @@ -122,6 +128,25 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const allowSkipConfirm = !isBatchDelete && modalData.allowSkipConfirm !== false && childWorkspaceCount === 0 const [dontAskAgain, setDontAskAgain] = useState(false) + const deleteTargets = useMemo( + () => (canDeleteAllLineage ? lineageDelete.deleteAllTargets : worktrees), + [canDeleteAllLineage, lineageDelete.deleteAllTargets, worktrees] + ) + const dirtyChangeCountsByWorktreeId = useMemo(() => { + const result = new Map() + for (const item of deleteTargets) { + if (item.isMainWorktree || getIsFolderWorkspaceDelete(repoMap, item)) { + continue + } + const statusEntries = gitStatusByWorktree[item.id] + if ((statusEntries?.length ?? 0) > 0) { + result.set(item.id, statusEntries?.length ?? 0) + } else if (deleteStateByWorktreeId[item.id]?.canForceDelete) { + result.set(item.id, 0) + } + } + return result + }, [deleteStateByWorktreeId, deleteTargets, gitStatusByWorktree, repoMap]) if (!isOpen && dontAskAgain) { // Why: this checkbox is a one-shot dialog intent; reset it as soon as the @@ -146,6 +171,42 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { worktrees.length ]) + useEffect(() => { + if (!isOpen) { + return + } + const statusTargets = deleteTargets.filter( + (item) => + !item.isMainWorktree && + !getIsFolderWorkspaceDelete(repoMap, item) && + gitStatusByWorktree[item.id] === undefined + ) + if (statusTargets.length === 0) { + return + } + let cancelled = false + for (const item of statusTargets) { + void getRuntimeGitStatus({ + settings, + worktreeId: item.id, + worktreePath: item.path, + connectionId: getConnectionId(item.id) ?? undefined + }) + .then((status) => { + if (!cancelled) { + setGitStatus(item.id, status) + } + }) + .catch(() => { + // Best-effort only: delete itself still performs the authoritative + // backend check and will surface failures through the normal toast. + }) + } + return () => { + cancelled = true + } + }, [deleteTargets, gitStatusByWorktree, isOpen, repoMap, setGitStatus, settings]) + const handleOpenChange = useCallback( (open: boolean) => { if (open) { @@ -233,7 +294,11 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { }) }) } else { + // Why: this modal is the destructive confirmation for the workspace + // folder. Running a non-force remove here just turns dirty files into + // a redundant Force Delete toast after the user already confirmed. const deletePromise = runWorktreeDeletesInParallel(worktrees, { + force: true, onForceDeleted: handleForceDeletedFromToast }) // Why: the workspace card owns the in-progress feedback, so the @@ -264,7 +329,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { if (lineageDelete.deleteAllTargets.length <= 1) { return } + // Why: the lineage modal confirms every affected workspace up front, so + // dirty child workspaces should not create per-workspace force prompts. const deletePromise = runWorktreeDeletesInParallel(lineageDelete.deleteAllTargets, { + force: true, onForceDeleted: handleForceDeletedFromToast }) // Why: deletion progress is shown on the workspace cards; the modal should @@ -315,127 +383,47 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { - {isBatchDelete ? ( - -
- {worktrees.map((item) => { - const itemDeleteState = deleteStateByWorktreeId[item.id] - return ( -
-
-
-
- {item.displayName} -
-
{item.path}
- {itemDeleteState?.error ? ( -
- {itemDeleteState.error} -
- ) : null} -
- {itemDeleteState?.isDeleting ? ( - - ) : null} -
-
- ) - })} -
-
- ) : worktree ? ( -
-
{worktree.displayName}
-
{worktree.path}
-
- ) : null} + {hasLineageChildren && ( - + )} - {isMainWorktree && ( -
-
- -
- This is the main worktree (the original clone - directory). {deleteCopy.mainWorktreeBlocker} -
-
-
- )} + - {deleteError && !isMainWorktree && ( -
-
- -
{deleteError}
-
-
- )} - - {!isMainWorktree && - allowSkipConfirm && - !canForceDelete && ( - // Why: only show "Don't ask again" for the primary confirmation. The - // force-delete variant is a recovery path that shouldn't double as a - // preference checkpoint; see handleDelete for the matching guard. - - )} + setDontAskAgain((prev) => !prev)} + /> - - {!isMainWorktree && - (canForceDelete ? ( - - ) : ( - - ))} + handleOpenChange(false)} + onForceDelete={() => handleDelete(true)} + onDelete={canDeleteAllLineage ? handleDeleteAll : () => handleDelete(false)} + confirmButtonRef={confirmButtonRef} + /> diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialogFooter.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialogFooter.tsx new file mode 100644 index 000000000..a6e31f6b3 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialogFooter.tsx @@ -0,0 +1,60 @@ +import type { JSX, Ref } from 'react' +import { LoaderCircle, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' + +export function DeleteWorktreeDialogFooter({ + isMainWorktree, + isDeleting, + canForceDelete, + isBatchDelete, + worktreeCount, + canDeleteAllLineage, + lineageDeleteTargetCount, + onCancel, + onForceDelete, + onDelete, + confirmButtonRef +}: { + isMainWorktree: boolean + isDeleting: boolean + canForceDelete: boolean + isBatchDelete: boolean + worktreeCount: number + canDeleteAllLineage: boolean + lineageDeleteTargetCount: number + onCancel: () => void + onForceDelete: () => void + onDelete: () => void + confirmButtonRef: Ref +}): JSX.Element { + const label = isDeleting + ? canForceDelete + ? 'Force Deleting...' + : 'Deleting...' + : isBatchDelete + ? `Delete ${worktreeCount} Workspaces` + : canDeleteAllLineage + ? `Delete ${lineageDeleteTargetCount} Workspaces` + : canForceDelete + ? 'Force Delete' + : 'Delete Workspace' + + return ( + <> + + {!isMainWorktree && ( + + )} + + ) +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.tsx new file mode 100644 index 000000000..a5936f146 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.tsx @@ -0,0 +1,32 @@ +import type { JSX } from 'react' +import { AlertTriangle } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +export function DeleteWorktreeDirtyChangeHint({ + changeCount +}: { + changeCount: number | undefined +}): JSX.Element | null { + if (changeCount === undefined) { + return null + } + + const label = + changeCount > 0 + ? `${changeCount} uncommitted or untracked ${changeCount === 1 ? 'change' : 'changes'}` + : 'Uncommitted or untracked changes' + + return ( + + +
+ + {label} +
+
+ + Deleting this workspace permanently removes these changes from disk. + +
+ ) +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx index 1201840b9..a70e97664 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx @@ -1,13 +1,16 @@ import { Workflow } from 'lucide-react' import type { JSX } from 'react' import type { Worktree } from '../../../../shared/types' +import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' type DeleteWorktreeLineageNoticeProps = { descendants: readonly Worktree[] + dirtyChangeCountsByWorktreeId: ReadonlyMap } export function DeleteWorktreeLineageNotice({ - descendants + descendants, + dirtyChangeCountsByWorktreeId }: DeleteWorktreeLineageNoticeProps): JSX.Element | null { const childWorkspaceCount = descendants.length if (childWorkspaceCount === 0) { @@ -32,6 +35,9 @@ export function DeleteWorktreeLineageNotice({
{child.displayName}
{child.path}
+
))} {descendants.length > 4 ? ( diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeSkipConfirmOption.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeSkipConfirmOption.tsx new file mode 100644 index 000000000..33a90672d --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeSkipConfirmOption.tsx @@ -0,0 +1,37 @@ +import type { JSX } from 'react' +import { Check } from 'lucide-react' + +export function DeleteWorktreeSkipConfirmOption({ + showDontAskAgain, + dontAskAgain, + onToggleDontAskAgain +}: { + showDontAskAgain: boolean + dontAskAgain: boolean + onToggleDontAskAgain: () => void +}): JSX.Element | null { + if (!showDontAskAgain) { + return null + } + + return ( + + ) +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx new file mode 100644 index 000000000..e6165e8c4 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx @@ -0,0 +1,65 @@ +import type { JSX } from 'react' +import { LoaderCircle } from 'lucide-react' +import { ScrollArea } from '@/components/ui/scroll-area' +import type { Worktree } from '../../../../shared/types' +import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' + +type DeleteState = { + isDeleting?: boolean + error?: string | null +} + +export function DeleteWorktreeTargetPreview({ + isBatchDelete, + worktree, + worktrees, + deleteStateByWorktreeId, + dirtyChangeCountsByWorktreeId +}: { + isBatchDelete: boolean + worktree: Worktree | null + worktrees: readonly Worktree[] + deleteStateByWorktreeId: Record + dirtyChangeCountsByWorktreeId: ReadonlyMap +}): JSX.Element | null { + if (isBatchDelete) { + return ( + +
+ {worktrees.map((item) => { + const itemDeleteState = deleteStateByWorktreeId[item.id] + return ( +
+
+
+
{item.displayName}
+
{item.path}
+ + {itemDeleteState?.error ? ( +
+ {itemDeleteState.error} +
+ ) : null} +
+ {itemDeleteState?.isDeleting ? ( + + ) : null} +
+
+ ) + })} +
+
+ ) + } + + return worktree ? ( +
+
{worktree.displayName}
+
{worktree.path}
+ +
+ ) : null +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx new file mode 100644 index 000000000..b632ebe80 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx @@ -0,0 +1,37 @@ +import type { JSX } from 'react' +import { AlertTriangle } from 'lucide-react' + +export function DeleteWorktreeWarningPanels({ + isMainWorktree, + mainWorktreeBlocker, + deleteError +}: { + isMainWorktree: boolean + mainWorktreeBlocker: string + deleteError: string | null +}): JSX.Element { + return ( + <> + {isMainWorktree && ( +
+
+ +
+ This is the main worktree (the + original clone directory). {mainWorktreeBlocker} +
+
+
+ )} + + {deleteError && !isMainWorktree && ( +
+
+ +
{deleteError}
+
+
+ )} + + ) +} diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index 1e829374a..c8c6d7f24 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -16,6 +16,7 @@ type WorktreeBatchDeleteOptions = { } type WorktreeDeleteWithToastOptions = { + force?: boolean onForceDeleted?: (worktreeId: string) => void } @@ -110,7 +111,7 @@ export function runWorktreeDeleteWithToast( ): Promise { const removeWorktree = useAppStore.getState().removeWorktree - return removeWorktree(worktreeId, false) + return removeWorktree(worktreeId, options.force === true) .then((result) => { if (result.ok) { return true diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index 81a219bce..e20214d66 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -132,6 +132,19 @@ describe('runWorktreeDeletesInParallel', () => { expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'parent', false) }) + it('passes confirmed force to each delete', async () => { + await runWorktreeDeletesInParallel( + [ + { id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' }, + { id: 'wt-2', displayName: 'two', repoId: 'repo-b', path: '/workspaces/two' } + ], + { force: true } + ) + + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'wt-1', true) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', true) + }) + it('clears a pending ancestor when a nested descendant delete fails', async () => { mocks.state.removeWorktree.mockImplementationOnce(async (worktreeId: string) => { mocks.state.deleteStateByWorktreeId[worktreeId] = { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 08df53f9b..07e773e2b 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1365,12 +1365,13 @@ export const createWorktreeSlice: StateCreator } }) get().removeWorkspaceSpaceWorktrees?.([worktreeId]) - showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { - void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) - }) - return removalResult?.preservedBranch - ? { ok: true as const, preservedBranch: removalResult.preservedBranch } - : { ok: true as const } + const preservedBranch = removalResult?.preservedBranch + if (preservedBranch) { + showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { + void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) + }) + } + return preservedBranch ? { ok: true as const, preservedBranch } : { ok: true as const } } catch (err) { // Why: git refusing a non-force delete for dirty/untracked files is a // handled user decision point surfaced by the delete toast, not an app error.