From 023d65ebfa75fecf9b48c09e1d172a33530eba3d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:29:19 -0700 Subject: [PATCH] feat(worktree): add skip delete confirmation preference (#771) - feat(worktree): add skip delete confirmation preference --- .../components/right-sidebar/PRActions.tsx | 13 +- .../src/components/settings/GeneralPane.tsx | 38 +++++ .../src/components/settings/general-search.ts | 5 + .../sidebar/DeleteWorktreeDialog.tsx | 156 +++++++++++------- .../sidebar/WorktreeContextMenu.tsx | 18 +- .../sidebar/delete-worktree-flow.ts | 75 +++++++++ src/shared/constants.ts | 1 + src/shared/types.ts | 6 + 8 files changed, 252 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/components/sidebar/delete-worktree-flow.ts diff --git a/src/renderer/src/components/right-sidebar/PRActions.tsx b/src/renderer/src/components/right-sidebar/PRActions.tsx index 725a5b1625e..bcb51d55324 100644 --- a/src/renderer/src/components/right-sidebar/PRActions.tsx +++ b/src/renderer/src/components/right-sidebar/PRActions.tsx @@ -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 }): 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(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 diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 6aec07a590c..20e7bd74241 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -292,6 +292,44 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea /> + + {/* 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. */} +
+ +
+ +

+ Delete worktrees from the context menu without a confirmation dialog. Errors still + surface as a toast with a Force Delete fallback. +

+
+ +
+
) : null, matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? ( diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 806051a250a..757c8676125 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -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'] } ] diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 89482661575..19e3b417b99 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -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() { )} + {!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. + + )} +