diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 643b6497e5d..0795d6221a3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1416,7 +1416,9 @@ function App(): React.JSX.Element { hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, - filterRepoIds, + // Why: the store keeps this readonly for identity stability, but PersistedUI crosses to + // main, which owns a mutable array — copy at the boundary rather than widening the wire type. + filterRepoIds: [...filterRepoIds], // Why (#9002): activeView is deliberately NOT included here. It used to // ride this same 150ms writer (#8265), which meant every top-level view // switch scheduled a full durable-state save. The narrow preference diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts index 4161f3bb6f9..7c9c69357ce 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts @@ -3,7 +3,7 @@ import { isDefaultBranchWorkspace } from './visible-worktrees' export type AddRepoSkipFinalizationState = { activeRepoId: string | null - filterRepoIds: string[] + filterRepoIds: readonly string[] showActiveOnly: boolean hideDefaultBranchWorkspace: boolean showSleepingWorkspaces: boolean diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index f85083db406..206d81dbe38 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -186,7 +186,7 @@ export function computeVisibleWorktreeIds( worktreesByRepo: Record, sortedIds: string[], opts: { - filterRepoIds: string[] + filterRepoIds: readonly string[] showSleepingWorkspaces: boolean tabsByWorktree: Record[]> | null ptyIdsByTabId: Record | null diff --git a/src/renderer/src/store/slices/repo-filter-selection.ts b/src/renderer/src/store/slices/repo-filter-selection.ts new file mode 100644 index 00000000000..65ae6d344a8 --- /dev/null +++ b/src/renderer/src/store/slices/repo-filter-selection.ts @@ -0,0 +1,10 @@ +// Why: catalog refreshes re-filter this on every fetch; six identity-sensitive subscribers +// (App.tsx at the root among them) re-render on a new array even when nothing was pruned. +export function retainValidFilterRepoIds( + filterRepoIds: readonly string[], + validRepoIds: ReadonlySet +): readonly string[] { + return filterRepoIds.every((repoId) => validRepoIds.has(repoId)) + ? filterRepoIds + : filterRepoIds.filter((repoId) => validRepoIds.has(repoId)) +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 25c53d3b481..01800b02896 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -48,6 +48,7 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { reconcileFetchedRepos } from './repo-identity-reconcile' +import { retainValidFilterRepoIds } from './repo-filter-selection' import { mergeSshRepoReadoptions, reconcileReadoptedSshRepoRows, @@ -2102,7 +2103,7 @@ export const createRepoSlice: StateCreator = (set, ? {} : { folderWorkspacePathStatuses: {} }), activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, validRepoHostIdentities @@ -2186,7 +2187,7 @@ export const createRepoSlice: StateCreator = (set, ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), ...mergedProjectCompatibility, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, validRepoHostIdentities @@ -2268,7 +2269,7 @@ export const createRepoSlice: StateCreator = (set, const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) return { activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, validRepoHostIdentities diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 1bfa1d68d77..dbbc2977965 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -904,8 +904,8 @@ export type UISlice = { showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void - filterRepoIds: string[] - setFilterRepoIds: (ids: string[]) => void + filterRepoIds: readonly string[] + setFilterRepoIds: (ids: readonly string[]) => void collapsedGroups: Set toggleCollapsedGroup: (key: string) => void worktreeCardProperties: WorktreeCardProperty[]