mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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]: [] })
|
||||
})
|
||||
})
|
||||
+113
-151
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user