Improve worktree delete confirmation UX (#4354)

This commit is contained in:
Neil 2026-05-31 22:48:04 -07:00 committed by GitHub
parent 5f70eea7a0
commit 6f59ea18c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 405 additions and 129 deletions

View File

@ -16,6 +16,9 @@ const mocks = vi.hoisted(() => {
updateSettings: vi.fn(),
openSettingsTarget: vi.fn(),
openSettingsPage: vi.fn(),
settings: null,
gitStatusByWorktree: {} as Record<string, { path: string; status: 'modified' }[]>,
setGitStatus: vi.fn(),
deleteStateByWorktreeId: {}
}
return { state, buttonProps: [] as Record<string, unknown>[] }
@ -54,6 +57,12 @@ vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>
}))
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&apos;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(<DeleteWorktreeLineageNotice descendants={[child]} />)
const markup = renderToStaticMarkup(
<DeleteWorktreeLineageNotice
descendants={[child]}
dirtyChangeCountsByWorktreeId={new Map()}
/>
)
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(<DeleteWorktreeDialog />)
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(<DeleteWorktreeDialog />)
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

View File

@ -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<string, number>()
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() {
</DialogDescription>
</DialogHeader>
{isBatchDelete ? (
<ScrollArea className="max-h-48 rounded-md border border-border/70 bg-muted/35 text-xs">
<div className="space-y-1 px-3 py-2">
{worktrees.map((item) => {
const itemDeleteState = deleteStateByWorktreeId[item.id]
return (
<div
key={item.id}
className="min-w-0 border-b border-border/50 py-1 last:border-0"
>
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<div className="break-all font-medium text-foreground">
{item.displayName}
</div>
<div className="mt-0.5 break-all text-muted-foreground">{item.path}</div>
{itemDeleteState?.error ? (
<div className="mt-1 whitespace-pre-wrap break-all text-destructive">
{itemDeleteState.error}
</div>
) : null}
</div>
{itemDeleteState?.isDeleting ? (
<LoaderCircle className="mt-0.5 size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
)
})}
</div>
</ScrollArea>
) : worktree ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-medium text-foreground">{worktree.displayName}</div>
<div className="mt-1 break-all text-muted-foreground">{worktree.path}</div>
</div>
) : null}
<DeleteWorktreeTargetPreview
isBatchDelete={isBatchDelete}
worktree={worktree}
worktrees={worktrees}
deleteStateByWorktreeId={deleteStateByWorktreeId}
dirtyChangeCountsByWorktreeId={dirtyChangeCountsByWorktreeId}
/>
{hasLineageChildren && (
<DeleteWorktreeLineageNotice descendants={lineageDelete.descendants} />
<DeleteWorktreeLineageNotice
descendants={lineageDelete.descendants}
dirtyChangeCountsByWorktreeId={dirtyChangeCountsByWorktreeId}
/>
)}
{isMainWorktree && (
<div className="rounded-md border border-blue-500/40 bg-blue-500/8 px-3 py-2 text-xs text-blue-700 dark:text-blue-300">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1">
This is the <span className="font-semibold">main worktree</span> (the original clone
directory). {deleteCopy.mainWorktreeBlocker}
</div>
</div>
</div>
)}
<DeleteWorktreeWarningPanels
isMainWorktree={isMainWorktree}
mainWorktreeBlocker={deleteCopy.mainWorktreeBlocker}
deleteError={deleteError}
/>
{deleteError && !isMainWorktree && (
<div className="rounded-md border border-destructive/40 bg-destructive/8 px-3 py-2 text-xs text-destructive">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1 whitespace-pre-wrap break-all">{deleteError}</div>
</div>
</div>
)}
{!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.
<button
type="button"
role="checkbox"
aria-checked={dontAskAgain}
onClick={() => setDontAskAgain((prev) => !prev)}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
)}
<DeleteWorktreeSkipConfirmOption
showDontAskAgain={!isMainWorktree && allowSkipConfirm && !canForceDelete}
dontAskAgain={dontAskAgain}
onToggleDontAskAgain={() => setDontAskAgain((prev) => !prev)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>
{isMainWorktree ? 'Close' : 'Cancel'}
</Button>
{!isMainWorktree &&
(canForceDelete ? (
<Button
ref={confirmButtonRef}
variant="destructive"
onClick={() => handleDelete(true)}
disabled={isDeleting}
>
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 />}
{isDeleting ? 'Force Deleting…' : 'Force Delete'}
</Button>
) : (
<Button
ref={confirmButtonRef}
variant="destructive"
onClick={canDeleteAllLineage ? handleDeleteAll : () => handleDelete(false)}
disabled={isDeleting}
>
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 />}
{isDeleting
? 'Deleting…'
: isBatchDelete
? `Delete ${worktrees.length}`
: canDeleteAllLineage
? `Delete All ${lineageDelete.deleteAllTargets.length}`
: 'Delete'}
</Button>
))}
<DeleteWorktreeDialogFooter
isMainWorktree={isMainWorktree}
isDeleting={isDeleting}
canForceDelete={canForceDelete}
isBatchDelete={isBatchDelete}
worktreeCount={worktrees.length}
canDeleteAllLineage={canDeleteAllLineage}
lineageDeleteTargetCount={lineageDelete.deleteAllTargets.length}
onCancel={() => handleOpenChange(false)}
onForceDelete={() => handleDelete(true)}
onDelete={canDeleteAllLineage ? handleDeleteAll : () => handleDelete(false)}
confirmButtonRef={confirmButtonRef}
/>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -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<HTMLButtonElement>
}): JSX.Element {
const label = isDeleting
? canForceDelete
? 'Force Deleting...'
: 'Deleting...'
: isBatchDelete
? `Delete ${worktreeCount} Workspaces`
: canDeleteAllLineage
? `Delete ${lineageDeleteTargetCount} Workspaces`
: canForceDelete
? 'Force Delete'
: 'Delete Workspace'
return (
<>
<Button variant="outline" onClick={onCancel} disabled={isDeleting}>
{isMainWorktree ? 'Close' : 'Cancel'}
</Button>
{!isMainWorktree && (
<Button
ref={confirmButtonRef}
variant="destructive"
onClick={canForceDelete ? onForceDelete : onDelete}
disabled={isDeleting}
>
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 />}
{label}
</Button>
)}
</>
)
}

View File

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<div className="mt-1 flex w-fit max-w-full items-center gap-1.5 text-destructive">
<AlertTriangle className="size-3 shrink-0" />
<span className="min-w-0 truncate font-medium">{label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Deleting this workspace permanently removes these changes from disk.
</TooltipContent>
</Tooltip>
)
}

View File

@ -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<string, number>
}
export function DeleteWorktreeLineageNotice({
descendants
descendants,
dirtyChangeCountsByWorktreeId
}: DeleteWorktreeLineageNoticeProps): JSX.Element | null {
const childWorkspaceCount = descendants.length
if (childWorkspaceCount === 0) {
@ -32,6 +35,9 @@ export function DeleteWorktreeLineageNotice({
<div key={child.id} className="min-w-0 overflow-hidden">
<div className="truncate font-medium text-foreground">{child.displayName}</div>
<div className="truncate text-muted-foreground">{child.path}</div>
<DeleteWorktreeDirtyChangeHint
changeCount={dirtyChangeCountsByWorktreeId.get(child.id)}
/>
</div>
))}
{descendants.length > 4 ? (

View File

@ -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 (
<button
type="button"
role="checkbox"
aria-checked={dontAskAgain}
onClick={onToggleDontAskAgain}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
)
}

View File

@ -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<string, DeleteState | undefined>
dirtyChangeCountsByWorktreeId: ReadonlyMap<string, number>
}): JSX.Element | null {
if (isBatchDelete) {
return (
<ScrollArea className="max-h-48 rounded-md border border-border/70 bg-muted/35 text-xs">
<div className="space-y-1 px-3 py-2">
{worktrees.map((item) => {
const itemDeleteState = deleteStateByWorktreeId[item.id]
return (
<div key={item.id} className="min-w-0 border-b border-border/50 py-1 last:border-0">
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<div className="break-all font-medium text-foreground">{item.displayName}</div>
<div className="mt-0.5 break-all text-muted-foreground">{item.path}</div>
<DeleteWorktreeDirtyChangeHint
changeCount={dirtyChangeCountsByWorktreeId.get(item.id)}
/>
{itemDeleteState?.error ? (
<div className="mt-1 whitespace-pre-wrap break-all text-destructive">
{itemDeleteState.error}
</div>
) : null}
</div>
{itemDeleteState?.isDeleting ? (
<LoaderCircle className="mt-0.5 size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
)
})}
</div>
</ScrollArea>
)
}
return worktree ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-medium text-foreground">{worktree.displayName}</div>
<div className="mt-1 break-all text-muted-foreground">{worktree.path}</div>
<DeleteWorktreeDirtyChangeHint changeCount={dirtyChangeCountsByWorktreeId.get(worktree.id)} />
</div>
) : null
}

View File

@ -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 && (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1">
This is the <span className="font-semibold text-foreground">main worktree</span> (the
original clone directory). {mainWorktreeBlocker}
</div>
</div>
</div>
)}
{deleteError && !isMainWorktree && (
<div className="rounded-md border border-destructive/40 bg-destructive/8 px-3 py-2 text-xs text-destructive">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1 whitespace-pre-wrap break-all">{deleteError}</div>
</div>
</div>
)}
</>
)
}

View File

@ -16,6 +16,7 @@ type WorktreeBatchDeleteOptions = {
}
type WorktreeDeleteWithToastOptions = {
force?: boolean
onForceDeleted?: (worktreeId: string) => void
}
@ -110,7 +111,7 @@ export function runWorktreeDeleteWithToast(
): Promise<boolean> {
const removeWorktree = useAppStore.getState().removeWorktree
return removeWorktree(worktreeId, false)
return removeWorktree(worktreeId, options.force === true)
.then((result) => {
if (result.ok) {
return true

View File

@ -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] = {

View File

@ -1365,12 +1365,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
})
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.