Fix Source Control huge repo warning dismissal leak (#7686)
This commit is contained in:
parent
fcdcab7cd4
commit
d912482752
|
|
@ -58,6 +58,11 @@ import {
|
|||
type PrimaryAction,
|
||||
type RemoteOpKind
|
||||
} from './source-control-primary-action'
|
||||
import {
|
||||
beginHugeRepoWarningProbe,
|
||||
hasDismissedHugeRepoWarning,
|
||||
markHugeRepoWarningDismissed
|
||||
} from '@/lib/source-control-huge-repo-warning-dismissals'
|
||||
import {
|
||||
resolveDropdownItems,
|
||||
type DropdownActionKind,
|
||||
|
|
@ -486,11 +491,6 @@ function rewriteCompareBaseBranchFromCandidate(
|
|||
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = []
|
||||
const EMPTY_BRANCH_CHANGE_ENTRIES: GitBranchChangeEntry[] = []
|
||||
|
||||
// Why: the "too many changes — add folder to .gitignore?" warning shows at most
|
||||
// once per worktree per session (the analog of a "Don't show again" gate), so a
|
||||
// repo that stays huge across polls doesn't re-toast every refresh.
|
||||
const hugeRepoWarningDismissed = new Set<string>()
|
||||
|
||||
// Why: directional signifiers ahead of each primary action label. Commit
|
||||
// (✓) is affirmative; Push (↑) points in the direction data flows; Sync
|
||||
// (↕) is bidirectional; Publish gets a cloud-up to distinguish the
|
||||
|
|
@ -815,6 +815,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const commitInFlightRef = useRef<Record<string, boolean>>({})
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeWorktreeInstanceId = activeWorktree?.instanceId
|
||||
const activeGroupId = useAppStore((s) =>
|
||||
activeWorktreeId ? s.activeGroupIdByWorktree[activeWorktreeId] : undefined
|
||||
)
|
||||
|
|
@ -1321,18 +1322,23 @@ function SourceControlInner(): React.JSX.Element {
|
|||
if (!repositoryHuge || !activeWorktreeId || !worktreePath || activeConnectionId) {
|
||||
return
|
||||
}
|
||||
if (hugeRepoWarningDismissed.has(activeWorktreeId)) {
|
||||
const warningProbe = beginHugeRepoWarningProbe({
|
||||
id: activeWorktreeId,
|
||||
instanceId: activeWorktreeInstanceId
|
||||
})
|
||||
if (hasDismissedHugeRepoWarning(warningProbe)) {
|
||||
return
|
||||
}
|
||||
const worktreeId = activeWorktreeId
|
||||
let cancelled = false
|
||||
void window.api.git
|
||||
.findHugeFoldersToIgnore({ worktreePath })
|
||||
.then((folders) => {
|
||||
if (cancelled || folders.length === 0 || hugeRepoWarningDismissed.has(worktreeId)) {
|
||||
if (cancelled || folders.length === 0 || hasDismissedHugeRepoWarning(warningProbe)) {
|
||||
return
|
||||
}
|
||||
if (!markHugeRepoWarningDismissed(warningProbe)) {
|
||||
return
|
||||
}
|
||||
hugeRepoWarningDismissed.add(worktreeId)
|
||||
const folderName = folders[0]
|
||||
toast.warning(
|
||||
translate(
|
||||
|
|
@ -1347,6 +1353,11 @@ function SourceControlInner(): React.JSX.Element {
|
|||
'Add to .gitignore'
|
||||
),
|
||||
onClick: () => {
|
||||
// Why: the toast can outlive its worktree; a purged probe must
|
||||
// not write .gitignore in a same-path replacement.
|
||||
if (!hasDismissedHugeRepoWarning(warningProbe)) {
|
||||
return
|
||||
}
|
||||
void window.api.git
|
||||
.appendGitignore({ worktreePath, folderName })
|
||||
.then(() => refreshActiveGitStatus())
|
||||
|
|
@ -1360,7 +1371,14 @@ function SourceControlInner(): React.JSX.Element {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [repositoryHuge, activeWorktreeId, worktreePath, activeConnectionId, refreshActiveGitStatus])
|
||||
}, [
|
||||
repositoryHuge,
|
||||
activeWorktreeId,
|
||||
activeWorktreeInstanceId,
|
||||
worktreePath,
|
||||
activeConnectionId,
|
||||
refreshActiveGitStatus
|
||||
])
|
||||
|
||||
const refreshGitStatusAfterPullRequestGeneration = useCallback(
|
||||
async (context: PullRequestGenerationContext): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
HUGE_REPO_WARNING_DISMISSAL_MAX_WORKTREES,
|
||||
beginHugeRepoWarningProbe,
|
||||
clearHugeRepoWarningDismissalsForTests,
|
||||
forgetHugeRepoWarningDismissalsForWorktrees,
|
||||
getHugeRepoWarningDismissalCountForTests,
|
||||
getHugeRepoWarningStateCountForTests,
|
||||
hasDismissedHugeRepoWarning,
|
||||
markHugeRepoWarningDismissed
|
||||
} from '@/lib/source-control-huge-repo-warning-dismissals'
|
||||
|
||||
function worktree(id: string, instanceId: string = `${id}-instance`) {
|
||||
return { id, instanceId }
|
||||
}
|
||||
|
||||
function probe(id: string, instanceId: string = `${id}-instance`) {
|
||||
return beginHugeRepoWarningProbe(worktree(id, instanceId))
|
||||
}
|
||||
|
||||
describe('source-control huge repo warning dismissals', () => {
|
||||
afterEach(() => {
|
||||
clearHugeRepoWarningDismissalsForTests()
|
||||
})
|
||||
|
||||
it('stays capped through prolonged churn while retaining a reused entry', () => {
|
||||
const retainedProbe = probe('keep')
|
||||
expect(markHugeRepoWarningDismissed(retainedProbe)).toBe(true)
|
||||
|
||||
const churnCount = HUGE_REPO_WARNING_DISMISSAL_MAX_WORKTREES * 8
|
||||
for (let i = 0; i < churnCount; i += 1) {
|
||||
expect(markHugeRepoWarningDismissed(probe(`worktree-${i}`))).toBe(true)
|
||||
expect(hasDismissedHugeRepoWarning(retainedProbe)).toBe(true)
|
||||
if (i % HUGE_REPO_WARNING_DISMISSAL_MAX_WORKTREES === 0) {
|
||||
expect(getHugeRepoWarningStateCountForTests()).toBeLessThanOrEqual(
|
||||
HUGE_REPO_WARNING_DISMISSAL_MAX_WORKTREES
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expect(getHugeRepoWarningStateCountForTests()).toBe(HUGE_REPO_WARNING_DISMISSAL_MAX_WORKTREES)
|
||||
expect(hasDismissedHugeRepoWarning(retainedProbe)).toBe(true)
|
||||
expect(hasDismissedHugeRepoWarning(probe('worktree-0'))).toBe(false)
|
||||
expect(hasDismissedHugeRepoWarning(probe(`worktree-${churnCount - 1}`))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not count duplicate dismissals as new worktree entries', () => {
|
||||
const repeatedProbe = probe('worktree-a')
|
||||
expect(markHugeRepoWarningDismissed(repeatedProbe)).toBe(true)
|
||||
expect(markHugeRepoWarningDismissed(repeatedProbe)).toBe(true)
|
||||
|
||||
expect(getHugeRepoWarningDismissalCountForTests()).toBe(1)
|
||||
expect(hasDismissedHugeRepoWarning(repeatedProbe)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves visibility toggles but clears an authoritatively removed path', () => {
|
||||
const originalWorktree = worktree('repo::/reused-path', 'persisted-instance')
|
||||
const originalProbe = beginHugeRepoWarningProbe(originalWorktree)
|
||||
expect(markHugeRepoWarningDismissed(originalProbe)).toBe(true)
|
||||
|
||||
// Why: visibility filters can temporarily hide a still-live external
|
||||
// worktree, while an authoritative missing scan proves its lifecycle ended.
|
||||
forgetHugeRepoWarningDismissalsForWorktrees([])
|
||||
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe({ ...originalWorktree }))).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
forgetHugeRepoWarningDismissalsForWorktrees([originalWorktree.id])
|
||||
|
||||
// External recreation can reuse persisted metadata and instanceId.
|
||||
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe({ ...originalWorktree }))).toBe(
|
||||
false
|
||||
)
|
||||
expect(getHugeRepoWarningDismissalCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a late probe completion after authoritative removal', () => {
|
||||
const deferredProbe = probe('repo::/removed', 'persisted-instance')
|
||||
|
||||
forgetHugeRepoWarningDismissalsForWorktrees([deferredProbe.worktreeId])
|
||||
const replacementProbe = probe('repo::/removed', 'persisted-instance')
|
||||
|
||||
expect(markHugeRepoWarningDismissed(deferredProbe)).toBe(false)
|
||||
expect(hasDismissedHugeRepoWarning(replacementProbe)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// Why: keep a generous live/recent session working set while guaranteeing a
|
||||
// fixed ceiling without trusting visibility-filtered worktree ownership maps.
|
||||
export const HUGE_REPO_WARNING_DISMISSAL_MAX_WORKTREES = 1024
|
||||
|
||||
export type HugeRepoWarningWorktreeIdentity = {
|
||||
id: string
|
||||
instanceId?: string
|
||||
}
|
||||
|
||||
export type HugeRepoWarningProbe = {
|
||||
readonly worktreeId: string
|
||||
readonly instanceId: string
|
||||
readonly lifecycleToken: symbol
|
||||
}
|
||||
|
||||
type HugeRepoWarningWorktreeState = {
|
||||
instanceId: string
|
||||
lifecycleToken: symbol
|
||||
dismissed: boolean
|
||||
}
|
||||
|
||||
const hugeRepoWarningStateByWorktreeId = new Map<string, HugeRepoWarningWorktreeState>()
|
||||
|
||||
function refreshHugeRepoWarningState(
|
||||
worktreeId: string,
|
||||
state: HugeRepoWarningWorktreeState
|
||||
): void {
|
||||
hugeRepoWarningStateByWorktreeId.delete(worktreeId)
|
||||
hugeRepoWarningStateByWorktreeId.set(worktreeId, state)
|
||||
while (hugeRepoWarningStateByWorktreeId.size > HUGE_REPO_WARNING_DISMISSAL_MAX_WORKTREES) {
|
||||
const oldestWorktreeId = hugeRepoWarningStateByWorktreeId.keys().next().value
|
||||
if (oldestWorktreeId === undefined) {
|
||||
break
|
||||
}
|
||||
hugeRepoWarningStateByWorktreeId.delete(oldestWorktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function beginHugeRepoWarningProbe(
|
||||
worktree: HugeRepoWarningWorktreeIdentity
|
||||
): HugeRepoWarningProbe {
|
||||
const instanceId = worktree.instanceId ?? ''
|
||||
let state = hugeRepoWarningStateByWorktreeId.get(worktree.id)
|
||||
if (!state || state.instanceId !== instanceId) {
|
||||
state = { instanceId, lifecycleToken: Symbol(worktree.id), dismissed: false }
|
||||
}
|
||||
refreshHugeRepoWarningState(worktree.id, state)
|
||||
return {
|
||||
worktreeId: worktree.id,
|
||||
instanceId,
|
||||
lifecycleToken: state.lifecycleToken
|
||||
}
|
||||
}
|
||||
|
||||
export function hasDismissedHugeRepoWarning(probe: HugeRepoWarningProbe): boolean {
|
||||
const state = hugeRepoWarningStateByWorktreeId.get(probe.worktreeId)
|
||||
if (
|
||||
!state ||
|
||||
state.lifecycleToken !== probe.lifecycleToken ||
|
||||
!state.dismissed
|
||||
) {
|
||||
return false
|
||||
}
|
||||
refreshHugeRepoWarningState(probe.worktreeId, state)
|
||||
return true
|
||||
}
|
||||
|
||||
export function markHugeRepoWarningDismissed(probe: HugeRepoWarningProbe): boolean {
|
||||
const state = hugeRepoWarningStateByWorktreeId.get(probe.worktreeId)
|
||||
if (!state || state.lifecycleToken !== probe.lifecycleToken) {
|
||||
return false
|
||||
}
|
||||
state.dismissed = true
|
||||
refreshHugeRepoWarningState(probe.worktreeId, state)
|
||||
return true
|
||||
}
|
||||
|
||||
export function migrateHugeRepoWarningDismissal(
|
||||
oldWorktreeId: string,
|
||||
newWorktreeId: string
|
||||
): void {
|
||||
if (oldWorktreeId === newWorktreeId) {
|
||||
return
|
||||
}
|
||||
const state = hugeRepoWarningStateByWorktreeId.get(oldWorktreeId)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
hugeRepoWarningStateByWorktreeId.delete(oldWorktreeId)
|
||||
// Why: preserve the once-per-worktree choice while invalidating actions that
|
||||
// captured the path before the rename.
|
||||
refreshHugeRepoWarningState(newWorktreeId, {
|
||||
...state,
|
||||
lifecycleToken: Symbol(newWorktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
export function forgetHugeRepoWarningDismissalsForWorktrees(
|
||||
removedWorktreeIds: Iterable<string>
|
||||
): void {
|
||||
const removedIds = new Set(removedWorktreeIds)
|
||||
if (removedIds.size === 0) {
|
||||
return
|
||||
}
|
||||
for (const worktreeId of removedIds) {
|
||||
// Why: deleting the state also invalidates every outstanding probe token,
|
||||
// so late async completions cannot resurrect a removed worktree dismissal.
|
||||
hugeRepoWarningStateByWorktreeId.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearHugeRepoWarningDismissalsForTests(): void {
|
||||
hugeRepoWarningStateByWorktreeId.clear()
|
||||
}
|
||||
|
||||
export function getHugeRepoWarningStateCountForTests(): number {
|
||||
return hugeRepoWarningStateByWorktreeId.size
|
||||
}
|
||||
|
||||
export function getHugeRepoWarningDismissalCountForTests(): number {
|
||||
let count = 0
|
||||
for (const state of hugeRepoWarningStateByWorktreeId.values()) {
|
||||
if (state.dismissed) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
|
@ -21,6 +21,12 @@ import {
|
|||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import {
|
||||
beginHugeRepoWarningProbe,
|
||||
clearHugeRepoWarningDismissalsForTests,
|
||||
hasDismissedHugeRepoWarning,
|
||||
markHugeRepoWarningDismissed
|
||||
} from '@/lib/source-control-huge-repo-warning-dismissals'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
|
|
@ -350,6 +356,7 @@ describe('fetchWorktrees', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
clearHugeRepoWarningDismissalsForTests()
|
||||
})
|
||||
|
||||
it('does not notify subscribers when the fetched payload is unchanged', async () => {
|
||||
|
|
@ -882,7 +889,7 @@ describe('fetchWorktrees', () => {
|
|||
expect(getHostedReviewLinkMutationGenerationForTests(surviving.id)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('purges remembered state for hidden worktrees removed by an authoritative refresh', async () => {
|
||||
it('retains hidden state until an authoritative refresh removes the worktree', async () => {
|
||||
const store = createTestStore()
|
||||
const visible = makeWorktree({
|
||||
id: 'repo1::/path/visible',
|
||||
|
|
@ -891,6 +898,7 @@ describe('fetchWorktrees', () => {
|
|||
})
|
||||
const hidden = makeWorktree({
|
||||
id: 'repo1::/path/hidden',
|
||||
instanceId: 'persisted-hidden-instance',
|
||||
repoId: 'repo1',
|
||||
path: '/path/hidden'
|
||||
})
|
||||
|
|
@ -900,7 +908,9 @@ describe('fetchWorktrees', () => {
|
|||
ownership: 'external',
|
||||
visible: false
|
||||
}
|
||||
mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [visible]))
|
||||
mockApi.worktrees.listDetected
|
||||
.mockResolvedValueOnce(previousDetected)
|
||||
.mockResolvedValueOnce(makeDetectedResult('repo1', [visible]))
|
||||
store.setState({
|
||||
worktreesByRepo: { repo1: [visible] },
|
||||
detectedWorktreesByRepo: { repo1: previousDetected },
|
||||
|
|
@ -917,6 +927,11 @@ describe('fetchWorktrees', () => {
|
|||
[hidden.id]: [{ id: 'tab-hidden', worktreeId: hidden.id }]
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
markHugeRepoWarningDismissed(beginHugeRepoWarningProbe(hidden))
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe(hidden))).toBe(true)
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
|
|
@ -925,6 +940,51 @@ describe('fetchWorktrees', () => {
|
|||
expect(store.getState().rightSidebarExplorerViewByWorktree).toEqual({ [visible.id]: 'files' })
|
||||
expect(store.getState().tabsByWorktree[hidden.id]).toBeUndefined()
|
||||
expect(store.getState().sortEpoch).toBe(7)
|
||||
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe(hidden))).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a hidden dismissal across hydrated fetch-all delete and recreation', async () => {
|
||||
const store = createTestStore()
|
||||
const visible = makeWorktree({
|
||||
id: 'repo1::/path/visible',
|
||||
repoId: 'repo1',
|
||||
path: '/path/visible'
|
||||
})
|
||||
const hidden = makeWorktree({
|
||||
id: 'repo1::/path/reused',
|
||||
instanceId: 'persisted-reused-instance',
|
||||
repoId: 'repo1',
|
||||
path: '/path/reused'
|
||||
})
|
||||
const hiddenDetected = makeDetectedResult('repo1', [visible, hidden])
|
||||
hiddenDetected.worktrees[1] = {
|
||||
...hiddenDetected.worktrees[1],
|
||||
ownership: 'external',
|
||||
visible: false
|
||||
}
|
||||
const recreatedDetected = makeDetectedResult('repo1', [visible, hidden])
|
||||
mockApi.worktrees.listDetected
|
||||
.mockResolvedValueOnce(hiddenDetected)
|
||||
.mockResolvedValueOnce(makeDetectedResult('repo1', [visible]))
|
||||
.mockResolvedValueOnce(recreatedDetected)
|
||||
store.setState({
|
||||
repos: [
|
||||
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
hasHydratedWorktreePurge: true,
|
||||
worktreesByRepo: { repo1: [visible] },
|
||||
detectedWorktreesByRepo: { repo1: hiddenDetected }
|
||||
} as Partial<AppState>)
|
||||
markHugeRepoWarningDismissed(beginHugeRepoWarningProbe(hidden))
|
||||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe(hidden))).toBe(true)
|
||||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
// The backend can reuse persisted instance metadata for the same path.
|
||||
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe(hidden))).toBe(false)
|
||||
})
|
||||
|
||||
it('purges session-only tab keys after an authoritative refresh', async () => {
|
||||
|
|
@ -3215,6 +3275,47 @@ describe('removeWorktree state cleanup', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
clearHugeRepoWarningDismissalsForTests()
|
||||
})
|
||||
|
||||
it('invalidates huge-repo warning probes after successful explicit removal', async () => {
|
||||
const store = createTestStore()
|
||||
const removed = makeWorktree({
|
||||
id: 'repo1::/path/reused',
|
||||
instanceId: 'persisted-instance',
|
||||
repoId: 'repo1',
|
||||
path: '/path/reused'
|
||||
})
|
||||
store.setState({ worktreesByRepo: { repo1: [removed] } } as Partial<AppState>)
|
||||
const staleProbe = beginHugeRepoWarningProbe(removed)
|
||||
expect(markHugeRepoWarningDismissed(staleProbe)).toBe(true)
|
||||
|
||||
await store.getState().removeWorktree(removed.id)
|
||||
|
||||
// The same-path replacement can reuse persisted instance metadata.
|
||||
const replacementProbe = beginHugeRepoWarningProbe({ ...removed })
|
||||
expect(hasDismissedHugeRepoWarning(staleProbe)).toBe(false)
|
||||
expect(markHugeRepoWarningDismissed(staleProbe)).toBe(false)
|
||||
expect(hasDismissedHugeRepoWarning(replacementProbe)).toBe(false)
|
||||
})
|
||||
|
||||
it('retains huge-repo warning state when explicit removal fails', async () => {
|
||||
const store = createTestStore()
|
||||
const retained = makeWorktree({
|
||||
id: 'repo1::/path/retained',
|
||||
instanceId: 'retained-instance',
|
||||
repoId: 'repo1',
|
||||
path: '/path/retained'
|
||||
})
|
||||
store.setState({ worktreesByRepo: { repo1: [retained] } } as Partial<AppState>)
|
||||
const retainedProbe = beginHugeRepoWarningProbe(retained)
|
||||
expect(markHugeRepoWarningDismissed(retainedProbe)).toBe(true)
|
||||
mockApi.worktrees.remove.mockRejectedValueOnce(new Error('delete failed'))
|
||||
|
||||
const result = await store.getState().removeWorktree(retained.id)
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'delete failed' })
|
||||
expect(hasDismissedHugeRepoWarning(retainedProbe)).toBe(true)
|
||||
})
|
||||
|
||||
it('cleans up hosted review link mutation bookkeeping for the removed worktree', async () => {
|
||||
|
|
@ -6543,6 +6644,20 @@ describe('migrateWorktreeIdentity', () => {
|
|||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
resetHostedReviewLinkMutationGenerationForTests()
|
||||
clearHugeRepoWarningDismissalsForTests()
|
||||
})
|
||||
|
||||
it('carries a dismissal across rename while invalidating the old-path probe', () => {
|
||||
const store = createTestStore()
|
||||
const staleProbe = beginHugeRepoWarningProbe({ id: OLD, instanceId: 'persisted-instance' })
|
||||
expect(markHugeRepoWarningDismissed(staleProbe)).toBe(true)
|
||||
|
||||
store.getState().migrateWorktreeIdentity(OLD, NEW)
|
||||
|
||||
const renamedProbe = beginHugeRepoWarningProbe({ id: NEW, instanceId: 'persisted-instance' })
|
||||
expect(hasDismissedHugeRepoWarning(staleProbe)).toBe(false)
|
||||
expect(markHugeRepoWarningDismissed(staleProbe)).toBe(false)
|
||||
expect(hasDismissedHugeRepoWarning(renamedProbe)).toBe(true)
|
||||
})
|
||||
|
||||
it('re-keys worktree-scoped maps, pointers, the Set, and openFiles old->new', () => {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-deliver
|
|||
import { branchName } from '@/lib/git-utils'
|
||||
import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
|
||||
import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session'
|
||||
import {
|
||||
forgetHugeRepoWarningDismissalsForWorktrees,
|
||||
migrateHugeRepoWarningDismissal
|
||||
} from '@/lib/source-control-huge-repo-warning-dismissals'
|
||||
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
|
||||
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
|
@ -1882,6 +1886,9 @@ function buildWorktreeRenameState(
|
|||
function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<AppState> {
|
||||
const worktreeIdSet = new Set(worktreeIds)
|
||||
pruneHostedReviewLinkMutationGenerations(worktreeIdSet)
|
||||
// Why: every authoritative and explicit purge converges here, including
|
||||
// fetchAllWorktrees; centralizing prevents a deleted path inheriting UI state.
|
||||
forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet)
|
||||
|
||||
// Collect every tab id (and removed file id) we are about to orphan.
|
||||
const doomedTabIds = new Set<string>()
|
||||
|
|
@ -3227,6 +3234,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
{ timeoutMs: 60_000 }
|
||||
))
|
||||
|
||||
// Why: invalidate stale probes as soon as deletion is authoritative, so
|
||||
// an old toast cannot mutate a same-path replacement during UI teardown.
|
||||
forgetHugeRepoWarningDismissalsForWorktrees([worktreeId])
|
||||
|
||||
const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim()
|
||||
if (worktreeDisplayName) {
|
||||
try {
|
||||
|
|
@ -4784,6 +4795,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
if (oldWorktreeId === newWorktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: invalidate pre-rename toast actions before publishing the new path,
|
||||
// while carrying the dismissal forward for the same logical worktree.
|
||||
migrateHugeRepoWarningDismissal(oldWorktreeId, newWorktreeId)
|
||||
set((s) => buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId))
|
||||
migrateHostedReviewLinkMutationGeneration(oldWorktreeId, newWorktreeId)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue