diff --git a/src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts b/src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts new file mode 100644 index 00000000000..a1c945346e0 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { omitRecordKey, omitRecordKeys } from './record-key-omission' + +describe('omitRecordKey', () => { + it('returns the same record when the key is absent', () => { + const record = { a: 1 } + expect(omitRecordKey(record, 'b')).toBe(record) + }) + + it('returns a copy without the key when it is present', () => { + const record = { a: 1, b: 2 } + const next = omitRecordKey(record, 'b') + expect(next).not.toBe(record) + expect(next).toEqual({ a: 1 }) + expect(record).toEqual({ a: 1, b: 2 }) + }) + + it('drops a key whose value is undefined', () => { + const record = { a: undefined } + expect(omitRecordKey(record, 'a')).toEqual({}) + }) + + it('normalizes a missing record to an empty one, as spread-then-delete did', () => { + expect(omitRecordKey(undefined, 'a')).toEqual({}) + expect(omitRecordKeys(undefined, ['a'])).toEqual({}) + }) +}) + +describe('omitRecordKeys', () => { + it('returns the same record when none of the keys are present', () => { + const record = { a: 1 } + expect(omitRecordKeys(record, ['b', 'c'])).toBe(record) + expect(omitRecordKeys(record, new Set())).toBe(record) + }) + + it('copies once and drops every present key', () => { + const record = { a: 1, b: 2, c: 3 } + const next = omitRecordKeys(record, new Set(['a', 'c', 'missing'])) + expect(next).not.toBe(record) + expect(next).toEqual({ b: 2 }) + expect(record).toEqual({ a: 1, b: 2, c: 3 }) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/record-key-omission.ts b/src/renderer/src/store/slices/worktrees/teardown/record-key-omission.ts new file mode 100644 index 00000000000..1dded7dae5b --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/record-key-omission.ts @@ -0,0 +1,46 @@ +/** + * Key removal that keeps a record's identity when it had none of the keys. + * + * Why identity matters here: teardown rewrites dozens of store maps at once, and + * a removed worktree has an entry in only a few of them. Copying the rest anyway + * gives every one a new reference, which rerenders every component selecting it + * for no data change. + * + * Why nullish input yields `{}`: some worktree-isolation callers hand over states + * with a slice omitted, and the spread-then-delete this replaces normalized those + * to an empty record. Production always initialises them, so the fresh object here + * costs nothing at runtime. + */ + +export function omitRecordKeys( + record: Record | undefined, + keys: Iterable +): Record { + if (!record) { + return {} + } + let next: Record | null = null + for (const key of keys) { + if (!(key in record)) { + continue + } + next ??= { ...record } + delete next[key] + } + return next ?? record +} + +export function omitRecordKey( + record: Record | undefined, + key: string +): Record { + if (!record) { + return {} + } + if (!(key in record)) { + return record + } + const next = { ...record } + delete next[key] + return next +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts new file mode 100644 index 00000000000..74752b8a719 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '../../../types' +import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup' + +const REMOVED_ID = 'repo-1::/repos/one/removed' +const SURVIVING_ID = 'repo-1::/repos/one/kept' + +/** Only the maps this test asserts on; the cleanup reads them defensively. */ +function buildState(): AppState { + return { + worktreesByRepo: { 'repo-1': [] }, + tabsByWorktree: { [REMOVED_ID]: [], [SURVIVING_ID]: [] }, + openFiles: [], + everActivatedWorktreeIds: new Set(), + lastVisitedAtByWorktreeId: {}, + deleteStateByWorktreeId: {}, + sortEpoch: 0, + // Worktree-keyed maps that hold nothing for the removed worktree. + gitStatusByWorktree: { [SURVIVING_ID]: 'clean' }, + gitStatusHugeByWorktree: {}, + showDotfilesByWorktree: { [SURVIVING_ID]: true }, + expandedDirs: {}, + fileSearchStateByWorktree: {}, + layoutByWorktree: { [SURVIVING_ID]: 'grid' }, + groupsByWorktree: {}, + unifiedTabsByWorktree: {}, + // Tab-keyed maps with no entry for the removed worktree's tabs. + terminalLayoutsByTabId: { 'other-tab': 'single' }, + ptyIdsByTabId: {}, + expandedPaneByTabId: {} + } as unknown as AppState +} + +function removeWorktree(state: AppState): AppState { + let current = state + applyRemoveWorktreeSuccessState( + (update) => { + const patch = typeof update === 'function' ? update(current) : update + current = { ...current, ...patch } + }, + REMOVED_ID, + new Set(['removed-tab']) + ) + return current +} + +describe('removeWorktree map identity', () => { + it('keeps the reference of every map that held nothing for the removed worktree', () => { + const before = buildState() + + const after = removeWorktree(before) + + // A new reference here rerenders every component selecting the map, for no data change. + for (const field of [ + 'gitStatusByWorktree', + 'gitStatusHugeByWorktree', + 'showDotfilesByWorktree', + 'expandedDirs', + 'fileSearchStateByWorktree', + 'layoutByWorktree', + 'groupsByWorktree', + 'unifiedTabsByWorktree', + 'terminalLayoutsByTabId', + 'ptyIdsByTabId', + 'expandedPaneByTabId' + ] as const) { + expect(after[field], field).toBe(before[field]) + } + }) + + it('still drops the removed worktree from the maps that did hold it', () => { + const before = buildState() + Object.assign(before, { + gitStatusByWorktree: { [REMOVED_ID]: 'dirty', [SURVIVING_ID]: 'clean' }, + terminalLayoutsByTabId: { 'removed-tab': 'single', 'other-tab': 'single' } + }) + + const after = removeWorktree(before) + + expect(after.gitStatusByWorktree).not.toBe(before.gitStatusByWorktree) + expect(after.gitStatusByWorktree).toEqual({ [SURVIVING_ID]: 'clean' }) + expect(after.terminalLayoutsByTabId).toEqual({ 'other-tab': 'single' }) + expect(after.tabsByWorktree).toEqual({ [SURVIVING_ID]: [] }) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index bbd54e1c89c..1ca89dab322 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -4,6 +4,7 @@ import type { WorktreeSliceSet } from '../listing/worktree-slice-types' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings' +import { omitRecordKey, omitRecordKeys } from './record-key-omission' export function applyRemoveWorktreeSuccessState( set: WorktreeSliceSet, @@ -19,95 +20,87 @@ export function applyRemoveWorktreeSuccessState( for (const repoId of Object.keys(next)) { next[repoId] = next[repoId].filter((w) => w.id !== worktreeId) } - const nextTabs = { ...s.tabsByWorktree } - delete nextTabs[worktreeId] - const nextLayouts = { ...s.terminalLayoutsByTabId } - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - const nextAutomaticAgentResumeClaimsByTabId = { - ...s.automaticAgentResumeClaimsByTabId - } - const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } - const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } - const nextUnverifiedPtyLossTabIds = { ...s.unverifiedPtyLossTabIds } + const nextTabs = omitRecordKey(s.tabsByWorktree, worktreeId) + const nextLayouts = omitRecordKeys(s.terminalLayoutsByTabId, tabIds) + const nextPtyIdsByTabId = omitRecordKeys(s.ptyIdsByTabId, tabIds) + const nextRuntimePaneTitlesByTabId = omitRecordKeys(s.runtimePaneTitlesByTabId, tabIds) + const nextAutomaticAgentResumeClaimsByTabId = omitRecordKeys( + s.automaticAgentResumeClaimsByTabId, + tabIds + ) + const nextNativeChatLaunchPromptByTabId = omitRecordKeys( + s.nativeChatLaunchPromptByTabId, + tabIds + ) + const nextNativeChatLaunchDraftByTabId = omitRecordKeys(s.nativeChatLaunchDraftByTabId, tabIds) + const nextUnverifiedPtyLossTabIds = omitRecordKeys(s.unverifiedPtyLossTabIds, tabIds) // Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags. - const nextExpandedPaneByTabId = { ...s.expandedPaneByTabId } - const nextCanExpandPaneByTabId = { ...s.canExpandPaneByTabId } - for (const tabId of tabIds) { - delete nextLayouts[tabId] - delete nextPtyIdsByTabId[tabId] - delete nextRuntimePaneTitlesByTabId[tabId] - delete nextAutomaticAgentResumeClaimsByTabId[tabId] - delete nextNativeChatLaunchPromptByTabId[tabId] - delete nextNativeChatLaunchDraftByTabId[tabId] - delete nextUnverifiedPtyLossTabIds[tabId] - delete nextExpandedPaneByTabId[tabId] - delete nextCanExpandPaneByTabId[tabId] - } + const nextExpandedPaneByTabId = omitRecordKeys(s.expandedPaneByTabId, tabIds) + const nextCanExpandPaneByTabId = omitRecordKeys(s.canExpandPaneByTabId, tabIds) const nextDeleteState = removeDeleteStatesForWorktreeIds( s.deleteStateByWorktreeId, new Set([worktreeId]) ) - const nextLineage = { ...s.worktreeLineageById } - delete nextLineage[worktreeId] - const nextWorkspaceLineage = { ...s.workspaceLineageByChildKey } - delete nextWorkspaceLineage[worktreeWorkspaceKey(worktreeId)] + const nextLineage = omitRecordKey(s.worktreeLineageById, worktreeId) + const nextWorkspaceLineage = omitRecordKey( + s.workspaceLineageByChildKey, + worktreeWorkspaceKey(worktreeId) + ) // Clean up editor files belonging to this worktree const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId) - const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } - delete nextBrowserTabsByWorktree[worktreeId] - const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } - delete nextActiveFileIdByWorktree[worktreeId] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - delete nextActiveBrowserTabIdByWorktree[worktreeId] + const nextBrowserTabsByWorktree = omitRecordKey(s.browserTabsByWorktree, worktreeId) + const nextActiveFileIdByWorktree = omitRecordKey(s.activeFileIdByWorktree, worktreeId) + const nextActiveBrowserTabIdByWorktree = omitRecordKey( + s.activeBrowserTabIdByWorktree, + worktreeId + ) // Why: closeBrowserTab records a Cmd+Shift+T undo snapshot, but a deleted worktree's tabs can't be restored; purge it. - const nextRecentlyClosedBrowserTabsByWorktree = { - ...s.recentlyClosedBrowserTabsByWorktree - } - delete nextRecentlyClosedBrowserTabsByWorktree[worktreeId] - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - delete nextActiveTabTypeByWorktree[worktreeId] - const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree } - delete nextActiveTabIdByWorktree[worktreeId] - const nextTabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } + const nextRecentlyClosedBrowserTabsByWorktree = omitRecordKey( + s.recentlyClosedBrowserTabsByWorktree, + worktreeId + ) + const nextActiveTabTypeByWorktree = omitRecordKey(s.activeTabTypeByWorktree, worktreeId) + const nextActiveTabIdByWorktree = omitRecordKey(s.activeTabIdByWorktree, worktreeId) // Why: the tab strip persists visual order per worktree; drop the entry so stale tab IDs aren't retained. - delete nextTabBarOrderByWorktree[worktreeId] - const nextPendingReconnectTabByWorktree = { ...s.pendingReconnectTabByWorktree } - delete nextPendingReconnectTabByWorktree[worktreeId] + const nextTabBarOrderByWorktree = omitRecordKey(s.tabBarOrderByWorktree, worktreeId) + const nextPendingReconnectTabByWorktree = omitRecordKey( + s.pendingReconnectTabByWorktree, + worktreeId + ) // Why: split-tab layout/group state is worktree-owned; leaving it makes a deleted worktree look restorable. - const nextUnifiedTabsByWorktree = { ...s.unifiedTabsByWorktree } - delete nextUnifiedTabsByWorktree[worktreeId] - const nextGroupsByWorktree = { ...s.groupsByWorktree } - delete nextGroupsByWorktree[worktreeId] - const nextLayoutByWorktree = { ...s.layoutByWorktree } - delete nextLayoutByWorktree[worktreeId] - const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree } - delete nextActiveGroupIdByWorktree[worktreeId] + const nextUnifiedTabsByWorktree = omitRecordKey(s.unifiedTabsByWorktree, worktreeId) + const nextGroupsByWorktree = omitRecordKey(s.groupsByWorktree, worktreeId) + const nextLayoutByWorktree = omitRecordKey(s.layoutByWorktree, worktreeId) + const nextActiveGroupIdByWorktree = omitRecordKey(s.activeGroupIdByWorktree, worktreeId) // Why: git status/compare caches stop refreshing once the worktree is deleted; remove them so no stale badges/diffs linger. - const nextGitStatusByWorktree = { ...s.gitStatusByWorktree } - delete nextGitStatusByWorktree[worktreeId] - const nextGitStatusHeadByWorktree = { ...s.gitStatusHeadByWorktree } - delete nextGitStatusHeadByWorktree[worktreeId] - const nextGitBranchLineTotalByWorktree = { ...s.gitBranchLineTotalByWorktree } - delete nextGitBranchLineTotalByWorktree[worktreeId] - const nextGitIgnoredPathsByWorktree = { ...s.gitIgnoredPathsByWorktree } - delete nextGitIgnoredPathsByWorktree[worktreeId] - const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree } - delete nextGitConflictOperationByWorktree[worktreeId] - const nextTrackedConflictPathsByWorktree = { ...s.trackedConflictPathsByWorktree } - delete nextTrackedConflictPathsByWorktree[worktreeId] - const nextGitBranchChangesByWorktree = { ...s.gitBranchChangesByWorktree } - delete nextGitBranchChangesByWorktree[worktreeId] - const nextGitBranchCompareSummaryByWorktree = { ...s.gitBranchCompareSummaryByWorktree } - delete nextGitBranchCompareSummaryByWorktree[worktreeId] - const nextGitBranchCompareRequestKeyByWorktree = { - ...s.gitBranchCompareRequestKeyByWorktree - } - delete nextGitBranchCompareRequestKeyByWorktree[worktreeId] - const nextGitBranchCompareRequestStatusHeadByWorktree = { - ...s.gitBranchCompareRequestStatusHeadByWorktree - } - delete nextGitBranchCompareRequestStatusHeadByWorktree[worktreeId] + const nextGitStatusByWorktree = omitRecordKey(s.gitStatusByWorktree, worktreeId) + const nextGitStatusHeadByWorktree = omitRecordKey(s.gitStatusHeadByWorktree, worktreeId) + const nextGitBranchLineTotalByWorktree = omitRecordKey( + s.gitBranchLineTotalByWorktree, + worktreeId + ) + const nextGitIgnoredPathsByWorktree = omitRecordKey(s.gitIgnoredPathsByWorktree, worktreeId) + const nextGitConflictOperationByWorktree = omitRecordKey( + s.gitConflictOperationByWorktree, + worktreeId + ) + const nextTrackedConflictPathsByWorktree = omitRecordKey( + s.trackedConflictPathsByWorktree, + worktreeId + ) + const nextGitBranchChangesByWorktree = omitRecordKey(s.gitBranchChangesByWorktree, worktreeId) + const nextGitBranchCompareSummaryByWorktree = omitRecordKey( + s.gitBranchCompareSummaryByWorktree, + worktreeId + ) + const nextGitBranchCompareRequestKeyByWorktree = omitRecordKey( + s.gitBranchCompareRequestKeyByWorktree, + worktreeId + ) + const nextGitBranchCompareRequestStatusHeadByWorktree = omitRecordKey( + s.gitBranchCompareRequestStatusHeadByWorktree, + worktreeId + ) // Why: clean up per-file editor state for the removed worktree so stale drafts/view modes don't accumulate. const removedFileIds = new Set() for (const file of s.openFiles) { @@ -119,40 +112,27 @@ export function applyRemoveWorktreeSuccessState( removedFileIds.add(file.markdownPreviewSourceFileId) } } - const nextEditorDrafts = removedFileIds.size > 0 ? { ...s.editorDrafts } : s.editorDrafts - const nextMarkdownViewMode = - removedFileIds.size > 0 ? { ...s.markdownViewMode } : s.markdownViewMode - const nextMarkdownRichModeSizeOverride = - removedFileIds.size > 0 - ? { ...s.markdownRichModeSizeOverride } - : s.markdownRichModeSizeOverride - const nextEditorViewMode = removedFileIds.size > 0 ? { ...s.editorViewMode } : s.editorViewMode - const nextMarkdownFrontmatterVisible = - removedFileIds.size > 0 ? { ...s.markdownFrontmatterVisible } : s.markdownFrontmatterVisible + const nextEditorDrafts = omitRecordKeys(s.editorDrafts, removedFileIds) + const nextMarkdownViewMode = omitRecordKeys(s.markdownViewMode, removedFileIds) + const nextMarkdownRichModeSizeOverride = omitRecordKeys( + s.markdownRichModeSizeOverride, + removedFileIds + ) + const nextEditorViewMode = omitRecordKeys(s.editorViewMode, removedFileIds) + const nextMarkdownFrontmatterVisible = omitRecordKeys( + s.markdownFrontmatterVisible, + removedFileIds + ) // Why: editorCursorLine is keyed by fileId; clear it with the other per-file state so it doesn't leak. - const nextEditorCursorLine = - removedFileIds.size > 0 ? { ...s.editorCursorLine } : s.editorCursorLine - if (removedFileIds.size > 0) { - for (const fileId of removedFileIds) { - delete nextEditorDrafts[fileId] - delete nextMarkdownViewMode[fileId] - delete nextMarkdownRichModeSizeOverride[fileId] - delete nextEditorViewMode[fileId] - delete nextMarkdownFrontmatterVisible[fileId] - delete nextEditorCursorLine[fileId] - } - } - const nextExpandedDirs = { ...s.expandedDirs } - delete nextExpandedDirs[worktreeId] - const nextShowDotfilesByWorktree = { ...s.showDotfilesByWorktree } - delete nextShowDotfilesByWorktree[worktreeId] + const nextEditorCursorLine = omitRecordKeys(s.editorCursorLine, removedFileIds) + const nextExpandedDirs = omitRecordKey(s.expandedDirs, worktreeId) + const nextShowDotfilesByWorktree = omitRecordKey(s.showDotfilesByWorktree, worktreeId) // Why: clear the huge-status marker so it doesn't linger after the worktree is gone. - const nextGitStatusHugeByWorktree = { ...s.gitStatusHugeByWorktree } - delete nextGitStatusHugeByWorktree[worktreeId] - const nextRightSidebarExplorerViewByWorktree = { - ...s.rightSidebarExplorerViewByWorktree - } - delete nextRightSidebarExplorerViewByWorktree[worktreeId] + const nextGitStatusHugeByWorktree = omitRecordKey(s.gitStatusHugeByWorktree, worktreeId) + const nextRightSidebarExplorerViewByWorktree = omitRecordKey( + s.rightSidebarExplorerViewByWorktree, + worktreeId + ) // If the active file belonged to the removed worktree, clear it const activeFileCleared = s.activeFileId ? s.openFiles.some((f) => f.id === s.activeFileId && f.worktreeId === worktreeId) @@ -181,49 +161,31 @@ export function applyRemoveWorktreeSuccessState( expandedPaneByTabId: nextExpandedPaneByTabId, canExpandPaneByTabId: nextCanExpandPaneByTabId, deleteStateByWorktreeId: nextDeleteState, - baseStatusByWorktreeId: (() => { - const nextStatus = { ...s.baseStatusByWorktreeId } - delete nextStatus[worktreeId] - return nextStatus - })(), - remoteBranchConflictByWorktreeId: (() => { - const nextConflict = { ...s.remoteBranchConflictByWorktreeId } - delete nextConflict[worktreeId] - return nextConflict - })(), - fileSearchStateByWorktree: (() => { - const nextSearch = { ...s.fileSearchStateByWorktree } - // Why: file search state is worktree-scoped; clear it so another worktree can't inherit stale matches. - delete nextSearch[worktreeId] - return nextSearch - })(), + baseStatusByWorktreeId: omitRecordKey(s.baseStatusByWorktreeId, worktreeId), + remoteBranchConflictByWorktreeId: omitRecordKey( + s.remoteBranchConflictByWorktreeId, + worktreeId + ), + fileSearchStateByWorktree: omitRecordKey(s.fileSearchStateByWorktree, worktreeId), // Why: these worktree-keyed maps are re-keyed on rename but were missed by removal, leaking one entry each. - remoteStatusesByWorktree: (() => { - const next = { ...s.remoteStatusesByWorktree } - delete next[worktreeId] - return next - })(), - recentlyClosedEditorTabsByWorktree: (() => { - const next = { ...s.recentlyClosedEditorTabsByWorktree } - delete next[worktreeId] - return next - })(), - recentlyClosedTerminalTabsByWorktree: (() => { - const next = { ...s.recentlyClosedTerminalTabsByWorktree } - delete next[worktreeId] - return next - })(), + remoteStatusesByWorktree: omitRecordKey(s.remoteStatusesByWorktree, worktreeId), + recentlyClosedEditorTabsByWorktree: omitRecordKey( + s.recentlyClosedEditorTabsByWorktree, + worktreeId + ), + recentlyClosedTerminalTabsByWorktree: omitRecordKey( + s.recentlyClosedTerminalTabsByWorktree, + worktreeId + ), // Why: a deleted worktree's tabs can never be reopened; purge the kind list with the snapshot stacks above. - recentlyClosedTabKindsByWorktree: (() => { - const next = { ...s.recentlyClosedTabKindsByWorktree } - delete next[worktreeId] - return next - })(), - defaultTerminalTabsAppliedByWorktreeId: (() => { - const next = { ...s.defaultTerminalTabsAppliedByWorktreeId } - delete next[worktreeId] - return next - })(), + recentlyClosedTabKindsByWorktree: omitRecordKey( + s.recentlyClosedTabKindsByWorktree, + worktreeId + ), + defaultTerminalTabsAppliedByWorktreeId: omitRecordKey( + s.defaultTerminalTabsAppliedByWorktreeId, + worktreeId + ), activeWorktreeId: removedActiveWorktree ? null : s.activeWorktreeId, activeWorkspaceExecutionHostId: removedActiveWorktree ? null diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts index 52a76539499..d8e3b7b9e3b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts @@ -3,6 +3,7 @@ import type { WorkspaceLineage } from '../../../../../../shared/worktree/lineage import { isWorkspaceKey, worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' import { normalizeRightSidebarRoute } from '../../../right-sidebar-route' import type { WorktreePurgeDoomedIds } from './worktree-purge-doomed-ids' +import { omitRecordKeys } from './record-key-omission' export function createWorktreePurgeOmitters( s: AppState, @@ -11,31 +12,15 @@ export function createWorktreePurgeOmitters( ) { const { doomedTabIds, doomedPtyIds, doomedBrowserWorkspaceIds, doomedPageIds, removedFileIds } = doomed - const omitByWorktree = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const id of worktreeIdSet) { - if (id in out) { - delete out[id] - changed = true - } - } - return changed ? out : obj - } + const omitByWorktree = (obj: Record): Record => + omitRecordKeys(obj, worktreeIdSet) const omitWorkspaceLineageByWorktree = ( obj: Record - ): Record => { - let changed = false - const out = { ...obj } - for (const id of worktreeIdSet) { - const childKey = isWorkspaceKey(id) ? id : worktreeWorkspaceKey(id) - if (childKey in out) { - delete out[childKey] - changed = true - } - } - return changed ? out : obj - } + ): Record => + omitRecordKeys( + obj, + [...worktreeIdSet].map((id) => (isWorkspaceKey(id) ? id : worktreeWorkspaceKey(id))) + ) const pruneRightSidebarTabByWorktree = (): AppState['rightSidebarTabByWorktree'] => { const omitted = omitByWorktree(s.rightSidebarTabByWorktree) let changed = omitted !== s.rightSidebarTabByWorktree @@ -50,94 +35,40 @@ export function createWorktreePurgeOmitters( } return changed ? out : omitted } - const omitByTabId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const tabId of doomedTabIds) { - if (tabId in out) { - delete out[tabId] - changed = true - } - } - return changed ? out : obj - } + const omitByTabId = (obj: Record): Record => + omitRecordKeys(obj, doomedTabIds) const survivingTabIds = new Set( Object.entries(s.tabsByWorktree) .filter(([worktreeId]) => !worktreeIdSet.has(worktreeId)) .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) ) - const omitRetiredDirectSshLedgerByTabId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const tabId of doomedTabIds) { - if (!survivingTabIds.has(tabId) && tabId in out) { - delete out[tabId] - changed = true - } - } - return changed ? out : obj - } - const omitByPtyId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const ptyId of doomedPtyIds) { - if (ptyId in out) { - delete out[ptyId] - changed = true - } - } - return changed ? out : obj - } + const omitRetiredDirectSshLedgerByTabId = (obj: Record): Record => + omitRecordKeys( + obj, + [...doomedTabIds].filter((tabId) => !survivingTabIds.has(tabId)) + ) + const omitByPtyId = (obj: Record): Record => + omitRecordKeys(obj, doomedPtyIds) // Pane-scoped maps are keyed `${tabId}:${leafId}`; tabId never contains ":", so the prefix before the first ":" is the owning tab. const omitByPaneKeyTabPrefix = (obj: Record): Record => { // Null-tolerant like omitByTabId: some worktree-isolation callers omit these slices (production store always inits to {}). if (!obj) { return obj } - let changed = false - const out = { ...obj } - for (const paneKey of Object.keys(obj)) { - const sep = paneKey.indexOf(':') - if (sep > 0 && doomedTabIds.has(paneKey.slice(0, sep))) { - delete out[paneKey] - changed = true - } - } - return changed ? out : obj - } - const omitByBrowserWorkspaceId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const workspaceId of doomedBrowserWorkspaceIds) { - if (workspaceId in out) { - delete out[workspaceId] - changed = true - } - } - return changed ? out : obj - } - const omitByPageId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const pageId of doomedPageIds) { - if (pageId in out) { - delete out[pageId] - changed = true - } - } - return changed ? out : obj - } - const omitByFileId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const fileId of removedFileIds) { - if (fileId in out) { - delete out[fileId] - changed = true - } - } - return changed ? out : obj + return omitRecordKeys( + obj, + Object.keys(obj).filter((paneKey) => { + const sep = paneKey.indexOf(':') + return sep > 0 && doomedTabIds.has(paneKey.slice(0, sep)) + }) + ) } + const omitByBrowserWorkspaceId = (obj: Record): Record => + omitRecordKeys(obj, doomedBrowserWorkspaceIds) + const omitByPageId = (obj: Record): Record => + omitRecordKeys(obj, doomedPageIds) + const omitByFileId = (obj: Record): Record => + omitRecordKeys(obj, removedFileIds) return { omitByWorktree,