diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts
index 30066815370..27a5f100654 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
-const SOURCE = readFileSync(join(__dirname, 'DeleteWorktreeDialog.tsx'), 'utf8')
+const SOURCE = readFileSync(join(__dirname, 'use-delete-worktree-status-hydration.ts'), 'utf8')
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -14,15 +14,21 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
describe('DeleteWorktreeDialog host-context boundaries', () => {
it('preloads git status from the selected worktree owner instead of the focused host', () => {
- const effect = sourceBetween(
- SOURCE,
- 'const statusTargets = deleteTargets.filter(',
- 'return () => {'
- )
+ const effect = sourceBetween(SOURCE, 'const targets = deleteTargets.filter(', 'return () => {')
expect(effect).toContain('getSettingsForWorktreeRuntimeOwner')
expect(effect).toContain('worktreesByRepo: useAppStore.getState().worktreesByRepo')
- expect(effect).toContain('item.id')
- expect(effect).not.toContain('settings,\n worktreeId: item.id')
+ expect(effect).toContain('target.id')
+ expect(effect).not.toContain('settings,\n worktreeId: target.id')
+ })
+
+ it('does not restart pending status requests when one target hydrates', () => {
+ const effect = sourceBetween(SOURCE, 'useEffect(() => {', '}, [')
+
+ expect(SOURCE).not.toContain('useAppStore((state) => state.gitStatusByWorktree)')
+ expect(effect).toContain('useAppStore.getState().gitStatusByWorktree')
+ expect(effect).toContain('const controller = new AbortController()')
+ expect(effect).toContain('{ signal: controller.signal }')
+ expect(effect).toContain('controller.abort()')
})
})
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx
index 3106deda4c7..65700ef574b 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx
@@ -43,7 +43,9 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/store/selectors', () => ({
- useAllWorktrees: () => mocks.state.allWorktrees()
+ useAllWorktrees: () => mocks.state.allWorktrees(),
+ getWorktreeMapFromState: () =>
+ new Map(mocks.state.allWorktrees().map((worktree) => [worktree.id, worktree]))
}))
vi.mock('@/components/ui/dialog', () => ({
@@ -91,7 +93,12 @@ vi.mock('./active-worktree-focus-after-delete', () => ({
prepareActiveWorktreeFocusAfterDelete: () => vi.fn()
}))
+vi.mock('./stale-workspace-list-toast', () => ({
+ showWorkspaceListChangedToast: vi.fn()
+}))
+
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
+import { showWorkspaceListChangedToast } from './stale-workspace-list-toast'
function makeWorktree(id: string, path: string): Worktree {
return {
@@ -153,7 +160,11 @@ describe('DeleteWorktreeDialog lineage copy', () => {
it('shows child-delete copy and only a delete-all action when the workspace has children', async () => {
const parent = makeWorktree('Parent workspace', '/workspaces/parent')
const child = makeWorktree('Child workspace', '/workspaces/child')
- mocks.state.modalData = { worktreeId: parent.id }
+ mocks.state.modalData = {
+ worktreeId: parent.id,
+ worktreeDeleteIdentities: [{ id: parent.id, instanceId: parent.instanceId }],
+ lineageDeleteIdentities: [child, parent].map(({ id, instanceId }) => ({ id, instanceId }))
+ }
mocks.state.allWorktrees.mockReturnValue([parent, child])
mocks.state.worktreeLineageById = {
[child.id]: makeLineage(child, parent)
@@ -295,7 +306,11 @@ describe('DeleteWorktreeDialog lineage copy', () => {
it('notifies the dialog caller after a toast force delete succeeds', async () => {
const workspace = makeWorktree('Workspace', '/workspaces/workspace')
const onDeleted = vi.fn()
- mocks.state.modalData = { worktreeId: workspace.id, onDeleted }
+ mocks.state.modalData = {
+ worktreeId: workspace.id,
+ worktreeDeleteIdentities: [{ id: workspace.id, instanceId: workspace.instanceId }],
+ onDeleted
+ }
mocks.state.allWorktrees.mockReturnValue([workspace])
const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
@@ -318,4 +333,57 @@ describe('DeleteWorktreeDialog lineage copy', () => {
expect(onDeleted).toHaveBeenCalledWith([workspace.id])
})
+
+ it('rejects confirmation when the workspace instance changed after the dialog opened', async () => {
+ const original = makeWorktree('Workspace', '/workspaces/original')
+ const replacement = { ...original, instanceId: 'replacement-instance' }
+ mocks.state.modalData = {
+ worktreeId: original.id,
+ worktreeDeleteIdentities: [{ id: original.id, instanceId: original.instanceId }]
+ }
+ mocks.state.allWorktrees.mockReturnValue([original])
+
+ const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
+ renderToStaticMarkup()
+ mocks.state.allWorktrees.mockReturnValue([replacement])
+
+ const deleteButton = mocks.buttonProps.find((props) => props.variant === 'destructive') as
+ | { onClick?: (event: never) => void }
+ | undefined
+ deleteButton?.onClick?.(undefined as never)
+
+ expect(showWorkspaceListChangedToast).toHaveBeenCalledOnce()
+ expect(mocks.state.closeModal).toHaveBeenCalledOnce()
+ expect(runWorktreeDeletesInParallel).not.toHaveBeenCalled()
+ expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
+ })
+
+ it('rejects lineage confirmation when a descendant instance changed', async () => {
+ const parent = makeWorktree('Parent workspace', '/workspaces/parent')
+ const child = makeWorktree('Child workspace', '/workspaces/child')
+ const replacement = { ...child, instanceId: 'replacement-instance' }
+ mocks.state.modalData = {
+ worktreeId: parent.id,
+ worktreeDeleteIdentities: [{ id: parent.id, instanceId: parent.instanceId }],
+ lineageDeleteIdentities: [child, parent].map(({ id, instanceId }) => ({ id, instanceId }))
+ }
+ mocks.state.allWorktrees.mockReturnValue([parent, child])
+ mocks.state.worktreeLineageById = {
+ [child.id]: makeLineage(child, parent)
+ }
+
+ const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
+ renderToStaticMarkup()
+ mocks.state.allWorktrees.mockReturnValue([parent, replacement])
+
+ const deleteButton = mocks.buttonProps.find((props) => props.variant === 'destructive') as
+ | { onClick?: () => void }
+ | undefined
+ deleteButton?.onClick?.()
+
+ expect(showWorkspaceListChangedToast).toHaveBeenCalledOnce()
+ expect(mocks.state.closeModal).toHaveBeenCalledOnce()
+ expect(runWorktreeDeletesInParallel).not.toHaveBeenCalled()
+ expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
index 6bbe0581c9a..568faf329b4 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
@@ -9,9 +9,6 @@ import {
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { toast } from 'sonner'
-import { getConnectionId } from '@/lib/connection-context'
-import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
-import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete'
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
@@ -30,6 +27,8 @@ import {
isFolderWorkspaceDelete as getIsFolderWorkspaceDelete
} from './delete-worktree-dialog-copy'
import { translate } from '@/i18n/i18n'
+import { useDeleteWorktreeStatusHydration } from './use-delete-worktree-status-hydration'
+import { useConfirmedWorktreeDeleteTargets } from './use-confirmed-worktree-delete-targets'
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@@ -43,9 +42,7 @@ 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 : ''
@@ -58,6 +55,12 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
: [],
[modalData.worktreeIds, worktreeId]
)
+ const { worktreeDeleteIdentities, lineageDeleteIdentities, resolveConfirmedTargets } =
+ useConfirmedWorktreeDeleteTargets({
+ worktreeIdentityData: modalData.worktreeDeleteIdentities,
+ lineageIdentityData: modalData.lineageDeleteIdentities,
+ closeModal
+ })
const onDeleted =
typeof modalData.onDeleted === 'function'
? (modalData.onDeleted as (worktreeIds: string[]) => void)
@@ -146,6 +149,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
repoMap
})
}, [deleteStateByWorktreeId, deleteTargets, gitStatusByWorktree, repoMap])
+ useDeleteWorktreeStatusHydration({ isOpen, deleteTargets, repoMap })
if (!isOpen && dontAskAgain) {
// Why: this checkbox is a one-shot dialog intent; reset it as soon as the
@@ -170,47 +174,6 @@ 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({
- // Why: delete warnings inspect git state for the selected workspace;
- // a later focused-host switch must not make this preload query another host.
- settings: getSettingsForWorktreeRuntimeOwner(
- { repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo },
- item.id
- ),
- 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, repos, setGitStatus, settings])
-
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
@@ -254,6 +217,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
if (worktreeIds.length === 0) {
return
}
+ const currentWorktrees = resolveConfirmedTargets(worktreeDeleteIdentities, worktreeIds.length)
+ if (!currentWorktrees) {
+ return
+ }
// 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
@@ -303,7 +270,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
// 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, {
+ const deletePromise = runWorktreeDeletesInParallel(currentWorktrees, {
force: true,
onForceDeleted: handleForceDeletedFromToast
})
@@ -326,8 +293,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
persistDontAskAgainPreference,
removeWorktree,
worktreeIds.length,
+ worktreeDeleteIdentities,
worktreeId,
- worktrees
+ resolveConfirmedTargets
]
)
@@ -335,9 +303,16 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
if (lineageDelete.deleteAllTargets.length <= 1) {
return
}
+ const currentTargets = resolveConfirmedTargets(
+ lineageDeleteIdentities,
+ lineageDelete.deleteAllTargets.length
+ )
+ if (!currentTargets) {
+ 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, {
+ const deletePromise = runWorktreeDeletesInParallel(currentTargets, {
force: true,
onForceDeleted: handleForceDeletedFromToast
})
@@ -349,7 +324,14 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
onDeleted?.(deletedIds)
}
})
- }, [closeModal, handleForceDeletedFromToast, lineageDelete.deleteAllTargets, onDeleted])
+ }, [
+ closeModal,
+ handleForceDeletedFromToast,
+ lineageDelete.deleteAllTargets.length,
+ lineageDeleteIdentities,
+ onDeleted,
+ resolveConfirmedTargets
+ ])
return (