mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(worktree): add skip delete confirmation preference (#771)
- feat(worktree): add skip delete confirmation preference
This commit is contained in:
@@ -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'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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -120,6 +120,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
activeCodexManagedAccountId: null,
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
agentCmdOverrides: {},
|
||||
terminalMacOptionAsAlt: 'true'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user