From 00ee4fd4d80a08e4844818bfa31b20ca80a8bb35 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:13:41 -0700 Subject: [PATCH] fix(source-control): only dismiss entry failures from the owning worktre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../source-control-entry-failure-toast.test.ts | 17 ++++++++++++++++- .../source-control-entry-failure-toast.ts | 18 +++++++++++++++--- ...ce-control-entry-mutation-failures.test.tsx | 7 ++++++- .../commit/use-discard-confirmation.ts | 2 +- .../commit/use-entry-mutations.ts | 5 +++-- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts index 5f019d4f389..92f69010c9f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts @@ -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') }) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts index b5dc8fb08f5..13ab65c6fb8 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts @@ -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() diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx index cfeff01f345..e1c502d8f2c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx @@ -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') diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts index 8a99df72049..e3089e7e6d8 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts @@ -70,7 +70,7 @@ export function useSourceControlDiscardConfirmation({ }) return } - dismissSourceControlEntryFailureToast() + dismissSourceControlEntryFailureToast(activeWorktreeId) await refreshEntryMutationStatus(refreshActiveGitStatusAfterMutation) }, [activeWorktreeId, discardSingle, refreshActiveGitStatusAfterMutation, worktreePath] diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts index 278fecde123..2f56d7ce8ab 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts @@ -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]