diff --git a/src/renderer/src/lib/setup-script-prompt.test.ts b/src/renderer/src/lib/setup-script-prompt.test.ts index 70d226bc0cf..605142bc154 100644 --- a/src/renderer/src/lib/setup-script-prompt.test.ts +++ b/src/renderer/src/lib/setup-script-prompt.test.ts @@ -202,6 +202,59 @@ describe('setup script prompt inspection', () => { ).toEqual([getSetupScriptPromptDismissalKey(localIdentity)]) }) + it('reuses the input array when it is empty', () => { + const input: string[] = [] + expect( + filterSetupScriptPromptDismissalsToValidRepos( + input, + new Set([getRepoHostIdentityForParts('repo-1', 'local')]) + ) + ).toBe(input) + }) + + it('reuses the input array when every dismissal is already a valid host-identity key', () => { + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + const remoteIdentity = getRepoHostIdentityForParts('repo-2', 'ssh:host-a') + const input = [ + getSetupScriptPromptDismissalKey(localIdentity), + getSetupScriptPromptDismissalKey(remoteIdentity) + ] + expect( + filterSetupScriptPromptDismissalsToValidRepos( + input, + new Set([localIdentity, remoteIdentity]) + ) + ).toBe(input) + }) + + it('allocates when a legacy repo-id dismissal is rewritten to a host identity', () => { + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + const input = [getSetupScriptPromptDismissalKey('repo-1')] + const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity])) + expect(result).not.toBe(input) + expect(result).toEqual([getSetupScriptPromptDismissalKey(localIdentity)]) + }) + + it('allocates when a stale dismissal is dropped', () => { + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + const input = [ + getSetupScriptPromptDismissalKey(localIdentity), + getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts('gone', 'local')) + ] + const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity])) + expect(result).not.toBe(input) + expect(result).toEqual([getSetupScriptPromptDismissalKey(localIdentity)]) + }) + + it('allocates when a duplicate valid dismissal is deduped', () => { + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + const key = getSetupScriptPromptDismissalKey(localIdentity) + const input = [key, key] + const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity])) + expect(result).not.toBe(input) + expect(result).toEqual([key]) + }) + it('drops a legacy repo-id dismissal when that id exists on multiple hosts', () => { const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') const remoteIdentity = getRepoHostIdentityForParts('repo-1', 'runtime:windows') diff --git a/src/renderer/src/lib/setup-script-prompt.ts b/src/renderer/src/lib/setup-script-prompt.ts index 8bf56d13980..6adbc04b092 100644 --- a/src/renderer/src/lib/setup-script-prompt.ts +++ b/src/renderer/src/lib/setup-script-prompt.ts @@ -90,10 +90,21 @@ export function isSetupScriptPromptDismissed( return dismissedEntries.includes(getSetupScriptPromptDismissalKey(repoHostIdentity)) } +function isUnchangedDismissalList( + value: unknown, + next: readonly string[] +): value is readonly string[] { + return ( + Array.isArray(value) && + value.length === next.length && + value.every((entry, index) => entry === next[index]) + ) +} + export function filterSetupScriptPromptDismissalsToValidRepos( value: unknown, validRepoHostIdentities: Set -): string[] { +): readonly string[] { const unambiguousIdentityByRepoId = new Map() for (const identity of validRepoHostIdentities) { const separatorIndex = identity.indexOf('\0') @@ -117,10 +128,18 @@ export function filterSetupScriptPromptDismissalsToValidRepos( } } } + // Why: fetchRepos / fetchRuntimeEnvironmentRepos / validateRepoScopedUi assign + // this into set() on every catalog refresh. SetupScriptPromptCard Object.is- + // subscribes to the array, so a fresh copy on a no-op prune is a guaranteed miss. + // Compare against the original input, not the sanitize copy — sanitize always + // allocates, including for [] and already-valid host-identity keys. + if (isUnchangedDismissalList(value, next)) { + return value + } return next } -export function sanitizeSetupScriptPromptDismissals(value: unknown): string[] { +export function sanitizeSetupScriptPromptDismissals(value: unknown): readonly string[] { if (!Array.isArray(value)) { return [] } diff --git a/src/renderer/src/store/slices/repos-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-refresh-identity.test.ts index 7fd32cca5ed..5ebf8b6602d 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Project, Repo } from '../../../../shared/types' +import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' +import { getRepoHostIdentityForParts } from './repo-host-identity' import { createTestStore } from './store-test-helpers' // Why: every field here is load-bearing. A scalar-only repo reconciles even when the structural @@ -281,3 +283,21 @@ describe('repo filter identity across catalog refreshes', () => { expect(store.getState().filterRepoIds).toBe(first) }) }) + +describe('setup-script dismissal identity across catalog refreshes', () => { + it('keeps the dismissal array when a refetch prunes nothing', async () => { + const store = createTestStore() + store.setState({ + setupScriptPromptDismissedRepoIds: [ + getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts(repo.id, 'local')) + ] + }) + const first = store.getState().setupScriptPromptDismissedRepoIds + + await store.getState().fetchRepos() + + // Why: SetupScriptPromptCard Object.is-subscribes to this array. A no-op + // catalog refresh must not allocate just because the helper rebuilt next. + expect(store.getState().setupScriptPromptDismissedRepoIds).toBe(first) + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index dbbc2977965..cf2f2b126b7 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -853,7 +853,7 @@ export type UISlice = { ) => void markOrcaHookRepoAlwaysTrusted: (repoId: string) => void clearOrcaHookTrustForRepo: (repoId: string) => void - setupScriptPromptDismissedRepoIds: string[] + setupScriptPromptDismissedRepoIds: readonly string[] dismissSetupScriptPrompt: (repoHostIdentity: string) => void setupGuideSidebarDismissed: boolean setSetupGuideSidebarDismissed: (dismissed: boolean) => void