fix(source-control): only dismiss entry failures from the owning worktre

Track which worktree owns the shared entry-failure toast slot. When a mutation
completes, only dismiss the slot if the completing worktree is the one that
raised the failure — a slow retry in one worktree should not erase a failure
another worktree has since raised into the slot.
This commit is contained in:
Jinjing
2026-09-14 12:15:18 -07:00
parent a8d5587fc8
commit 00ee4fd4d8
5 changed files with 41 additions and 8 deletions
@@ -123,7 +123,22 @@ describe('showSourceControlEntryFailureToast', () => {
})
it('clears the shared slot when an attempt finally lands', () => {
dismissSourceControlEntryFailureToast()
show()
dismissSourceControlEntryFailureToast('wt-1')
expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation')
})
it('leaves a failure another worktree raised into the slot alone', () => {
// Why: a retry still in flight in the worktree the user left must not erase the failure the
// worktree they switched to has since raised into the shared slot.
show({ worktreeId: 'wt-1' })
storeState.activeWorktreeId = 'wt-2'
show({ worktreeId: 'wt-2', worktreeName: 'feature-b' })
dismissSourceControlEntryFailureToast('wt-1')
expect(toastDismiss).not.toHaveBeenCalled()
dismissSourceControlEntryFailureToast('wt-2')
expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation')
})
@@ -7,8 +7,19 @@ export type SourceControlEntryOperation = 'stage' | 'unstage' | 'discard'
const ENTRY_FAILURE_TOAST_ID = 'source-control-entry-mutation'
/** Clears the shared entry-failure slot once an attempt — or its retry — lands. */
export function dismissSourceControlEntryFailureToast(): void {
// Why: worktreeId is nullable, so an occupancy wrapper distinguishes an empty slot from a null-owned one.
let entryFailureSlotOwner: { worktreeId: string | null } | null = null
/**
* Clears the shared entry-failure slot once an attempt — or its retry — lands, but only when the
* completing attempt is the one that filled it: a slow retry in a worktree the user has left must
* not erase a failure the worktree they moved to has since raised into the same slot.
*/
export function dismissSourceControlEntryFailureToast(worktreeId: string | null): void {
if (!entryFailureSlotOwner || entryFailureSlotOwner.worktreeId !== worktreeId) {
return
}
entryFailureSlotOwner = null
toast.dismiss(ENTRY_FAILURE_TOAST_ID)
}
@@ -76,6 +87,7 @@ export function showSourceControlEntryFailureToast({
const isActiveWorktree = useAppStore.getState().activeWorktreeId === worktreeId
const title = entryFailureTitle(operation, filePath, deleteShaped)
const offerRetry = Boolean(onRetry) && isActiveWorktree
entryFailureSlotOwner = { worktreeId }
toast.error(
isActiveWorktree || !worktreeName
? title
@@ -99,7 +111,7 @@ export function showSourceControlEntryFailureToast({
// caller owns this slot instead: it dismisses on success and re-raises on failure.
event.preventDefault()
if (useAppStore.getState().activeWorktreeId !== worktreeId) {
dismissSourceControlEntryFailureToast()
dismissSourceControlEntryFailureToast(worktreeId)
return
}
onRetry()
@@ -155,12 +155,17 @@ describe('source-control entry mutation failures', () => {
})
it('leaves a successful stage silent, and clears a stale failure it supersedes', async () => {
mocks.stagePath.mockResolvedValue(undefined)
mocks.stagePath.mockRejectedValueOnce(new Error('index.lock exists'))
mocks.stagePath.mockResolvedValueOnce(undefined)
const { result } = renderMutations()
await act(async () => {
await result.current.handleStage('src/app.ts')
})
mocks.toastError.mockClear()
await act(async () => {
await result.current.handleStage('src/other.ts')
})
expect(mocks.toastError).not.toHaveBeenCalled()
expect(mocks.toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation')
@@ -70,7 +70,7 @@ export function useSourceControlDiscardConfirmation({
})
return
}
dismissSourceControlEntryFailureToast()
dismissSourceControlEntryFailureToast(activeWorktreeId)
await refreshEntryMutationStatus(refreshActiveGitStatusAfterMutation)
},
[activeWorktreeId, discardSingle, refreshActiveGitStatusAfterMutation, worktreePath]
@@ -66,8 +66,9 @@ export function useSourceControlEntryMutations({
})
return
}
// Why: the mutation landed, so clear any failure this attempt (or an earlier one) left in the slot.
dismissSourceControlEntryFailureToast()
// Why: the mutation landed, so clear any failure this worktree's attempts left in the slot —
// a failure another worktree raised meanwhile is not ours to dismiss.
dismissSourceControlEntryFailureToast(activeWorktreeId)
await refreshEntryMutationStatus(refreshActiveGitStatusAfterMutation)
},
[activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation]