feat(worktree): add skip delete confirmation preference (#771)

- feat(worktree): add skip delete confirmation preference
This commit is contained in:
Jinjing
2026-04-17 15:29:19 -07:00
committed by GitHub
parent 71fd14f369
commit 023d65ebfa
8 changed files with 252 additions and 60 deletions
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import { runWorktreeDeleteWithToast } from '../sidebar/delete-worktree-flow'
const MERGE_METHODS = ['squash', 'merge', 'rebase'] as const
@@ -26,6 +27,7 @@ export default function PRActions({
onRefreshPR: () => Promise<void>
}): React.JSX.Element | null {
const openModal = useAppStore((s) => s.openModal)
const skipDeleteConfirm = useAppStore((s) => s.settings?.skipDeleteWorktreeConfirm ?? false)
const [merging, setMerging] = useState(false)
const [mergeError, setMergeError] = useState<string | null>(null)
const [mergeMenuOpen, setMergeMenuOpen] = useState(false)
@@ -70,8 +72,17 @@ export default function PRActions({
}, [mergeMenuOpen])
const handleDeleteWorktree = useCallback(() => {
// Why: honor the user's "don't ask again" preference from the main
// worktree-delete dialog here too; otherwise the merged-PR shortcut would
// still prompt after the user opted out everywhere else. Main worktrees
// can't reach this action — PRs are opened from non-main worktrees — so
// we don't need the main-worktree guard the context menu uses.
if (skipDeleteConfirm) {
runWorktreeDeleteWithToast(worktree.id, worktree.displayName)
return
}
openModal('delete-worktree', { worktreeId: worktree.id })
}, [worktree.id, openModal])
}, [worktree.id, worktree.displayName, openModal, skipDeleteConfirm])
// Why: merging a PR with unresolved conflicts would fail on GitHub anyway;
// disabling the button prevents a confusing error and signals the user must
@@ -292,6 +292,44 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
/>
</button>
</SearchableSetting>
{/* Why: the "Don't ask again" toast in the delete-worktree dialog
deep-links here, so the wrapper id must stay stable. Renaming it
breaks that toast action even though this pane still renders fine. */}
<div id="general-skip-delete-worktree-confirm" className="scroll-mt-6">
<SearchableSetting
title="Skip Delete Worktree Confirmation"
description="Delete worktrees from the context menu without a confirmation dialog."
keywords={['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Skip Delete Worktree Confirmation</Label>
<p className="text-xs text-muted-foreground">
Delete worktrees from the context menu without a confirmation dialog. Errors still
surface as a toast with a Force Delete fallback.
</p>
</div>
<button
role="switch"
aria-checked={settings.skipDeleteWorktreeConfirm}
onClick={() =>
updateSettings({
skipDeleteWorktreeConfirm: !settings.skipDeleteWorktreeConfirm
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.skipDeleteWorktreeConfirm ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.skipDeleteWorktreeConfirm ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? (
@@ -10,6 +10,11 @@ export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
title: 'Nest Workspaces',
description: 'Create worktrees inside a repo-named subfolder.',
keywords: ['nested', 'subfolder', 'directory']
},
{
title: 'Skip Delete Worktree Confirmation',
description: 'Delete worktrees from the context menu without a confirmation dialog.',
keywords: ['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']
}
]
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -8,11 +8,10 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, LoaderCircle, Trash2 } from 'lucide-react'
import { AlertTriangle, Check, LoaderCircle, Trash2 } from 'lucide-react'
import { useAppStore } from '@/store'
import { toast } from 'sonner'
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { runWorktreeDeleteWithToast } from './delete-worktree-flow'
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@@ -21,6 +20,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const removeWorktree = useAppStore((s) => s.removeWorktree)
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
const allWorktrees = useAppStore((s) => s.allWorktrees)
const updateSettings = useAppStore((s) => s.updateSettings)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const isOpen = activeModal === 'delete-worktree'
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
@@ -40,6 +42,17 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
// always rejects it. We block the delete button upfront so the user doesn't have to
// discover this limitation via a confusing force-delete dead-end.
const isMainWorktree = worktree?.isMainWorktree ?? false
const [dontAskAgain, setDontAskAgain] = useState(false)
// Why: the checkbox is a one-shot intent captured inside the dialog — when
// the dialog closes (cancel, delete, or esc) we reset it so the next open
// starts unchecked. Without this, toggling the box and cancelling would
// silently re-surface the checked state on the next delete.
useEffect(() => {
if (!isOpen) {
setDontAskAgain(false)
}
}, [isOpen])
useEffect(() => {
if (isOpen && worktreeId && !worktree && !isDeleting) {
@@ -64,68 +77,71 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
[clearWorktreeDeleteState, closeModal, worktreeId]
)
const persistDontAskAgainPreference = useCallback((): void => {
void updateSettings({ skipDeleteWorktreeConfirm: true })
// Why: the toast confirms the preference was saved and points the user at
// where to undo it. The "Open Settings" action deep-links to the General
// pane so they never have to hunt for the toggle if they change their mind.
toast.success("We'll skip this confirmation next time.", {
description: 'You can change this in Settings.',
duration: 8000,
action: {
label: 'Open Settings',
onClick: () => {
openSettingsPage()
openSettingsTarget({
pane: 'general',
repoId: null,
sectionId: 'general-skip-delete-worktree-confirm'
})
}
}
})
}, [openSettingsPage, openSettingsTarget, updateSettings])
const handleDelete = useCallback(
(force = false) => {
if (!worktreeId) {
return
}
const targetWorktreeId = worktreeId
removeWorktree(targetWorktreeId, force)
.then((result) => {
if (!result.ok) {
const state = useAppStore.getState().deleteStateByWorktreeId[targetWorktreeId]
const toastCopy = getDeleteWorktreeToastCopy(
worktreeName,
state?.canForceDelete ?? false,
result.error
)
const showToast = toastCopy.isDestructive ? toast.error : toast.info
showToast(toastCopy.title, {
description: toastCopy.description,
duration: 10000,
cancel: {
label: 'View',
onClick: () => activateAndRevealWorktree(targetWorktreeId)
},
action: state?.canForceDelete
? {
label: 'Force Delete',
onClick: () => {
removeWorktree(targetWorktreeId, true)
.then((forceResult) => {
if (!forceResult.ok) {
toast.error('Force delete failed', {
description: forceResult.error,
action: {
label: 'View',
onClick: () => activateAndRevealWorktree(targetWorktreeId)
}
})
}
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : String(err),
action: {
label: 'View',
onClick: () => activateAndRevealWorktree(targetWorktreeId)
}
})
})
}
}
: undefined
})
}
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : String(err)
// Why: force-delete is a recovery path taken after a failed first delete.
// Saving "don't ask again" from that state would conflate the recovery
// action with a broader preference. Only persist the preference on the
// primary (non-force) confirmation so users intentionally opt in.
if (dontAskAgain && !force) {
persistDontAskAgainPreference()
}
if (force) {
// Why: this branch preserves the legacy "Force Delete" button behavior
// inside the dialog — it runs the destructive retry directly without
// the shared toast wrapper, since the user is already looking at an
// error state and a success silently closes the dialog.
removeWorktree(worktreeId, true)
.then((result) => {
if (!result.ok) {
toast.error('Force delete failed', {
description: result.error
})
}
})
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : String(err)
})
})
} else {
runWorktreeDeleteWithToast(worktreeId, worktreeName)
}
closeModal()
},
[closeModal, removeWorktree, worktreeId, worktreeName]
[
closeModal,
dontAskAgain,
persistDontAskAgainPreference,
removeWorktree,
worktreeId,
worktreeName
]
)
return (
@@ -182,6 +198,30 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
</div>
)}
{!isMainWorktree && !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-muted-foreground 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/50 bg-transparent'
}`}
>
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
)}
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>
{isMainWorktree ? 'Close' : 'Cancel'}
@@ -22,6 +22,7 @@ import {
import { useAppStore } from '@/store'
import type { Worktree } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { runWorktreeDeleteWithToast } from './delete-worktree-flow'
type Props = {
worktree: Worktree
@@ -34,6 +35,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const skipDeleteConfirm = useAppStore((s) => s.settings?.skipDeleteWorktreeConfirm ?? false)
const shutdownWorktreeTerminals = useAppStore((s) => s.shutdownWorktreeTerminals)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
@@ -124,14 +126,28 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
return
}
clearWorktreeDeleteState(worktree.id)
// Why: when the user has opted into skipping the confirmation, jump
// straight to the same delete-with-toast flow the dialog would run on
// confirm. The force-delete fallback still surfaces through the toast's
// "Force Delete" action, so the user never silently loses dirty work —
// they just skip the redundant "are you sure?" step for clean deletes.
// The dialog stays the entry point for the main worktree (guarded at the
// DropdownMenuItem level) and for any worktree that becomes unavailable
// mid-action, because those cases produce dialog-specific UI.
if (skipDeleteConfirm && !worktree.isMainWorktree) {
runWorktreeDeleteWithToast(worktree.id, worktree.displayName)
return
}
openModal('delete-worktree', { worktreeId: worktree.id })
}, [
worktree.id,
worktree.repoId,
worktree.displayName,
worktree.isMainWorktree,
clearWorktreeDeleteState,
isFolder,
openModal
openModal,
skipDeleteConfirm
])
return (
@@ -0,0 +1,75 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
/**
* Shared delete-with-toast flow used by both DeleteWorktreeDialog (confirm
* path) and WorktreeContextMenu (skip-confirm path). Centralizes the error
* toast copy, the "Force Delete" action wiring, and the "View" affordance so
* both entry points behave identically from the user's perspective.
*
* Why this is a module helper rather than a store action: the behavior is
* intrinsically UI-shaped — it shows sonner toasts, registers action/cancel
* handlers, and depends on `activateAndRevealWorktree` (a renderer-only
* helper). Keeping it in the renderer layer avoids bleeding toast/UI
* concerns into the store slice while still preventing the two delete
* entry points from drifting apart.
*/
export function runWorktreeDeleteWithToast(worktreeId: string, worktreeName: string): void {
const removeWorktree = useAppStore.getState().removeWorktree
removeWorktree(worktreeId, false)
.then((result) => {
if (result.ok) {
return
}
const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
const canForceDelete = state?.canForceDelete ?? false
const toastCopy = getDeleteWorktreeToastCopy(worktreeName, canForceDelete, result.error)
const showToast = toastCopy.isDestructive ? toast.error : toast.info
showToast(toastCopy.title, {
description: toastCopy.description,
duration: 10000,
cancel: {
label: 'View',
onClick: () => activateAndRevealWorktree(worktreeId)
},
action: canForceDelete
? {
label: 'Force Delete',
onClick: () => {
useAppStore
.getState()
.removeWorktree(worktreeId, true)
.then((forceResult) => {
if (!forceResult.ok) {
toast.error('Force delete failed', {
description: forceResult.error,
action: {
label: 'View',
onClick: () => activateAndRevealWorktree(worktreeId)
}
})
}
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : String(err),
action: {
label: 'View',
onClick: () => activateAndRevealWorktree(worktreeId)
}
})
})
}
}
: undefined
})
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : String(err)
})
})
}
+1
View File
@@ -120,6 +120,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
activeCodexManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
defaultTaskViewPreset: 'all',
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'true'
+6
View File
@@ -617,6 +617,12 @@ export type GlobalSettings = {
terminalScopeHistoryByWorktree: boolean
/** Which agent to pre-select in the new-workspace composer. null = auto (first detected). */
defaultTuiAgent: TuiAgent | null
/** Why: worktree deletion is destructive (git worktree remove + rm -rf of the
* working directory), so Orca shows a confirmation dialog by default. Users
* who delete frequently can opt into skipping the dialog via a "Don't ask
* again" checkbox inside it or from the General settings pane. We keep this
* defaulted to false so first-time behavior stays safe. */
skipDeleteWorktreeConfirm: boolean
/** Default preset in the new-workspace GitHub task view. */
defaultTaskViewPreset: TaskViewPresetId
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */