perf(worktrees): stop worktree removal from replacing maps it never touched

applyRemoveWorktreeSuccessState spread-then-deleted about 50 store maps on every
worktree removal. A removed worktree has an entry in only a few of them, so the
rest were handed back with a new reference and identical contents — rerendering
every component selecting them, git status caches and split-tab layout included.

The sibling purge path already had the right contract (`return changed ? out :
obj`) inlined into nine near-identical closures. That contract moves to
omitRecordKey/omitRecordKeys, the removal cascade adopts it, and the purge
omitters drop their duplicated copies.

The one behaviour to preserve carefully: `{ ...undefined }` normalised an omitted
slice to `{}`, and some worktree-isolation callers do hand over states with
slices missing. The helper keeps that, so a nullish record still yields `{}`
rather than throwing on `in` or leaking undefined into the store.
This commit is contained in:
Neil
2026-09-06 02:36:19 -07:00
parent 3d48d3a481
commit aeb05003ab
5 changed files with 317 additions and 250 deletions
@@ -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<string>())).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 })
})
})
@@ -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<T>(
record: Record<string, T> | undefined,
keys: Iterable<string>
): Record<string, T> {
if (!record) {
return {}
}
let next: Record<string, T> | null = null
for (const key of keys) {
if (!(key in record)) {
continue
}
next ??= { ...record }
delete next[key]
}
return next ?? record
}
export function omitRecordKey<T>(
record: Record<string, T> | undefined,
key: string
): Record<string, T> {
if (!record) {
return {}
}
if (!(key in record)) {
return record
}
const next = { ...record }
delete next[key]
return next
}
@@ -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<string>(),
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]: [] })
})
})
@@ -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<string>()
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
@@ -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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> =>
omitRecordKeys(obj, worktreeIdSet)
const omitWorkspaceLineageByWorktree = (
obj: Record<string, WorkspaceLineage>
): Record<string, WorkspaceLineage> => {
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<string, WorkspaceLineage> =>
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> =>
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> =>
omitRecordKeys(
obj,
[...doomedTabIds].filter((tabId) => !survivingTabIds.has(tabId))
)
const omitByPtyId = <T>(obj: Record<string, T>): Record<string, T> =>
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 = <T>(obj: Record<string, T>): Record<string, T> => {
// 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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> =>
omitRecordKeys(obj, doomedBrowserWorkspaceIds)
const omitByPageId = <T>(obj: Record<string, T>): Record<string, T> =>
omitRecordKeys(obj, doomedPageIds)
const omitByFileId = <T>(obj: Record<string, T>): Record<string, T> =>
omitRecordKeys(obj, removedFileIds)
return {
omitByWorktree,