Improve worktree delete confirmation UX (#4354)
This commit is contained in:
parent
5f70eea7a0
commit
6f59ea18c5
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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't ask again
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue